From b457f0acc819c6fb4e96cc49ecaddf07bd106dfe Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 20:42:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(m179):=20=E5=A4=96=E8=A7=82=E5=88=86?= =?UTF-8?q?=E7=BB=84=E6=8E=A5=E7=9C=9F=E8=AF=AD=E8=A8=80=E5=B9=B6=E6=8B=86?= =?UTF-8?q?=E6=88=90=E4=BA=94=E6=AE=B5=E6=96=87=E4=BB=B6=EF=BC=88=E5=8F=AA?= =?UTF-8?q?=E6=8E=A5=E7=AE=A1=E5=AD=90=EF=BC=8C=E4=B8=8D=E8=A1=A5=E6=96=87?= =?UTF-8?q?=E6=A1=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M172 审计:`pages/appearance/**` 中文界面下真正本地化 0/27,两个根因各断一刀。 ① 拆文件(纯搬移):`section.rs` 395 行 → `section/` 五个文件,行数守卫只剩 5 行余量, 不拆就加不了任何东西。 - `section/mod.rs`(壳:section() + card() + 五个行控件 + 取词口 t) - `section/window.rs`(① 整窗 α + ② 圆角,含 notice/说明段) - `section/panel.rs`(③ 面板 α)、`section/bg_image.rs`(④⑤⑥⑦⑧)、 `section/colour_source.rs`(⑨) 取证:每段与原文件做「去空白/去尾逗号」diff,4/4 相同;只有五处机械改写 (`rows = rows.push(…)`→`rows.push(…)`、`t(Key::X)`→`t(Key::X, lang)`、 `super::notice`→`super::super::notice`、一处 `.into()`、rustfmt 换行)。 ② 语言接到渲染层:`settings::Body` 从 `fn(&Ui, States)` 变成 `fn(&Ui, States, Lang)`, `form` 传 `language.effective`,`settings/appearance.rs` 收 `lang` 转给 `pages::appearance::section(ui, state, lang)`,`section::t(key, lang)` 取词。 选「接进唯一那个槽位」而不是再给 `form` 加一条按下标分发的 `else if`:`settings/mod.rs` 贴着 400 行硬顶(M195 还要在此落地),这条路净增 0 行且不可能与分发表漂开。 验收点:`pages/appearance/**` 里已经搜不到 `Lang::En`。 ③ 能变红的守卫:`the_appearance_group_speaks_chinese_when_the_interface_does` —— 走 `settings::view` 真分发渲染一帧,断言取词记录里含汉字、英文那帧不含汉字且同一 key 中英译文不同。取证口是 `appearance::probe`(`#[cfg(test)]` thread_local):iced 0.14 没有可遍历控件树的公开接口(`Widget::children()` 返回状态 Tree;`operate` 要 Renderer), 所以取证点退到「字进树之前」的唯一取词口。变异证明:把 `t()` 改回 `Lang::En.catalog()` ⇒ 该用例红(失败信息逐条列出四条英文);还原 ⇒ 绿。 只接语言管子,不补文案:本组其余英文仍是字面量(行号见 M179 报告)。真实窗口截图、 macOS/Linux 字体差异未验。 --- crates/app-iced/src/pages/appearance/mod.rs | 6 +- crates/app-iced/src/pages/appearance/probe.rs | 46 ++ .../app-iced/src/pages/appearance/section.rs | 395 ------------------ .../src/pages/appearance/section/bg_image.rs | 109 +++++ .../pages/appearance/section/colour_source.rs | 41 ++ .../src/pages/appearance/section/mod.rs | 258 ++++++++++++ .../src/pages/appearance/section/panel.rs | 45 ++ .../src/pages/appearance/section/window.rs | 86 ++++ .../app-iced/src/pages/settings/appearance.rs | 15 +- crates/app-iced/src/pages/settings/mod.rs | 27 +- .../settings/tests/appearance_section.rs | 121 +++++- .../pages/settings/tests/general_section.rs | 8 +- .../src/pages/settings/tests/java_section.rs | 3 +- .../app-iced/src/pages/settings/tests/mod.rs | 7 +- 14 files changed, 745 insertions(+), 422 deletions(-) create mode 100644 crates/app-iced/src/pages/appearance/probe.rs delete mode 100644 crates/app-iced/src/pages/appearance/section.rs create mode 100644 crates/app-iced/src/pages/appearance/section/bg_image.rs create mode 100644 crates/app-iced/src/pages/appearance/section/colour_source.rs create mode 100644 crates/app-iced/src/pages/appearance/section/mod.rs create mode 100644 crates/app-iced/src/pages/appearance/section/panel.rs create mode 100644 crates/app-iced/src/pages/appearance/section/window.rs diff --git a/crates/app-iced/src/pages/appearance/mod.rs b/crates/app-iced/src/pages/appearance/mod.rs index 1cbda1c..ccb2505 100644 --- a/crates/app-iced/src/pages/appearance/mod.rs +++ b/crates/app-iced/src/pages/appearance/mod.rs @@ -20,7 +20,7 @@ //! | [`background`] | 背景图解码(png/jpeg/bmp)、平铺网格、暗化系数、可读诊断 | 读盘一次(`load`) | //! | `palette` | D38 三级优先序的调用侧 + 提示文案 | 无(像素由 `background` 给) | //! | `state` | 运行期状态:把上面三件的输入输出连起来 | 只在 `refresh_background` | -//! | `section` | 设置页「外观」分组(真控件) | 无 | +//! | `section/` | 设置页「外观」分组(真控件):壳 + 五段控件,M179 从单文件拆开 | 无 | //! | `picture` | 背景层 Element:真图直出(Cover/Contain/Tile)+ 暗化叠层 | 无 | //! //! ## 偏离登记(D30 要求逐条写清) @@ -65,10 +65,12 @@ pub mod background; mod palette; mod picture; +#[cfg(test)] +pub(crate) mod probe; mod section; mod state; -/// 状态提示的 **tonal notice**(`section.rs` 的 400 行硬顶把它放在模块根)。 +/// 状态提示的 **tonal notice**(行数硬顶把 `section/` 撑开之后它仍放在模块根)。 /// /// `SurfaceContainerHighest` 底 + `ShapeScale::Small` 圆角 + 两侧 `X2` 内边距,文字 `OnSurfaceVariant`。 /// 用它而不是「换色的正文」:容器才是 M3 表达「当前状态」的形态,也和说明段在**形态**上分开 diff --git a/crates/app-iced/src/pages/appearance/probe.rs b/crates/app-iced/src/pages/appearance/probe.rs new file mode 100644 index 0000000..2c340c2 --- /dev/null +++ b/crates/app-iced/src/pages/appearance/probe.rs @@ -0,0 +1,46 @@ +//! **测试专用**:记录「画一帧时从词表取出的每一条译文」。 +//! +//! ## 为什么需要这一层 +//! +//! 判据是「中文界面下外观分组渲染出来的字里必须含汉字」,但 iced 0.14 **没有**可以遍历控件树的 +//! 公开接口: +//! +//! - `Element::as_widget()` 拿到的是 `&dyn Widget`,而 `Widget::children()` 返回的是**状态** `Tree` +//! (不是子控件),没有 `as_any` 之类的向下转型口; +//! - 想走 `Widget::operate` 得自己造 `Tree` + 跑布局 + 传 `Renderer`,而单测里**没有渲染器** +//! (`pages/download/tests/options_screen.rs` 记的正是这条边界)。 +//! +//! 所以取证点退到「字进树之前」那一层:`section::t` 是本分组**唯一**的取词口,它把每条译文 +//! 顺手记一份,测试再断言这批字里有没有汉字。这不是「看源码字符串」那种假守卫——它要求真的把 +//! [`crate::pages::appearance::section`] 跑一遍(并且走 `settings::view` 的语言分发),只是不画像素。 +//! +//! ## 为什么是 thread-local +//! +//! `cargo test` 默认多线程跑,每个测试一个线程;thread-local 让并发测试的取证互不串台, +//! 也不需要加锁或串行化。 +//! +//! 整个模块只在 `#[cfg(test)]` 下编译,产品路径零代价。 + +use std::cell::RefCell; + +use unlucky_i18n::catalog::Key; + +// 自上次 `take` 以来记录下的 `(key, 译文)`,顺序 = 取词顺序。 +thread_local! { + // clippy 1.99.1(= 本仓 CI 的 stable)对这条 lint **误报**:初始化器已经写成 `const { … }` + // 它还是报「can be made const」。最小复现(clippy-driver 原地可验,无本仓任何符号): + // `thread_local!{ static X: RefCell> = const { RefCell::new(Vec::new()) }; }` + // —— 同一个文件加不加 `const` 报同一句。等 clippy 修掉这条误报即可删掉本行 allow。 + #[allow(clippy::missing_const_for_thread_local)] + static TEXTS: 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))); +} + +/// 取走并清空本线程记录下的取词(测试用)。 +pub(crate) fn take() -> Vec<(Key, &'static str)> { + TEXTS.with(|texts| std::mem::take(&mut *texts.borrow_mut())) +} diff --git a/crates/app-iced/src/pages/appearance/section.rs b/crates/app-iced/src/pages/appearance/section.rs deleted file mode 100644 index 04e3ada..0000000 --- a/crates/app-iced/src/pages/appearance/section.rs +++ /dev/null @@ -1,395 +0,0 @@ -//! 设置页「外观」分组(**真控件**)+ 背景层 Element + 面板层配色。 -//! -//! ## 控件与键的对应 -//! -//! | 控件 | 键 | 改动消息 | -//! |---|---|---| -//! | 整窗透明度滑块(下限 0.08) | `appearance.window_opacity` | [`Change::WindowOpacity`] | -//! | 窗口圆角(跟随系统 / 直角,M116) | `appearance.window_corner` | [`Change::Corner`] | -//! | 面板透明度滑块(下限 0.4,CPU 档禁用) | `appearance.panel_alpha` | [`Change::PanelAlpha`] | -//! | 背景图开关 | `appearance.bg_image_enabled` | [`Change::BgEnabled`] | -//! | 背景图路径(文本框) | `appearance.bg_image` | [`Change::BgImage`] | -//! | 填充(封面 / 拉伸 / 平铺) | `appearance.bg_fill` | [`Change::BgFill`] | -//! | 暗化强度(0~0.95) | `appearance.bg_dim` | [`Change::BgDim`] | -//! | 配色来源(三档) | `appearance.palette_source` | [`Change::PaletteSource`] | -//! -//! 文案一律英文基线(i18n 是别的 mission;本文件不拼中文、不 import 尚不存在的 crate)。 -//! -//! ## 为什么「值」与「生效值」分开显示 -//! -//! CPU 档下面板 α 被强制 1.0(D36),而用户请求值可能是 0.5。滑块位置按**生效值**画、旁边写 -//! 「requested 0.50 → forced 1.00」,这样「为什么拖了没反应」在界面上就有答案,而不是只躺在日志里。 - -use iced::widget::{button, column, container, row, space}; -use iced::{ - Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme as IcedTheme, -}; - -use unlucky_design::color::Role; -use unlucky_design::component_map::Component; -use unlucky_design::elevation::MaterialLayer; -use unlucky_design::shape::ShapeScale; -use unlucky_design::spacing::Space; -use unlucky_design::typography::TypeRole; -use unlucky_i18n::catalog::Key; -use unlucky_presenter::prefs::{BgFill, PaletteSource, WindowCorner}; - -use crate::app::components::inputs::{slider, text_field, Field, Slider}; -use crate::pages::{frame, Message}; -use crate::theme::Ui; - -use super::alpha::{ - panel_alpha_from_fraction, panel_fraction, window_alpha_from_fraction, window_fraction, - WINDOW_ALPHA_FLOOR, -}; -use super::background::{self, BG_DIM_MAX}; -use super::state::State; -use super::Change; - -/// 「外观」分组整块(一张卡)。 -pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> { - let mut rows = column![].spacing(ui.space(Space::X1)).width(Length::Fill); - - // ① 整窗透明度 - let window_percent = format!("{:.0}%", state.window_opacity * 100.0); - rows = rows.push(slider_row( - ui, - "Window transparency", - window_percent, - format!( - "Applies to the whole window. Floor {:.2}; 1.00 = fully opaque.", - WINDOW_ALPHA_FLOOR - ), - state.window_opacity < 1.0, - slider( - Slider::new(ui, window_fraction(state.window_opacity)).on_change(|fraction| { - Message::Appearance(Change::WindowOpacity(window_alpha_from_fraction(fraction))) - }), - ), - )); - - // ② 窗口圆角(M116):只有两档 + 一句按平台说明;不做 8/12/16 档(系统不暴露半径)。 - let corner = state.window_corner(); - rows = rows.push(choice_row( - ui, - t(Key::SettingsAppearanceCorner), - vec![ - choice( - ui, - t(Key::SettingsAppearanceCornerSystem), - corner == WindowCorner::System, - Change::Corner(WindowCorner::System), - ), - choice( - ui, - t(Key::SettingsAppearanceCornerSquare), - corner == WindowCorner::Square, - Change::Corner(WindowCorner::Square), - ), - ], - )); - // **tonal notice**(r5 视觉终稿):M3 里表达「当前状态」的惯用形态是**容器**,不是改字色。 - // 只在「跟随系统 + α<1」出现;与下面的说明段之间再留 `Space::X1`(列间距 X1 + 这里 X1 = X2)。 - if corner == WindowCorner::System && state.window_opacity < 1.0 { - rows = rows.push(super::notice(ui, t(Key::SettingsAppearanceCornerSuspended))); - let gap: Element<'a, Message> = space::vertical().height(ui.space(Space::X1)).into(); - rows = rows.push(gap); - } - let tone = ui.color(Role::OnSurfaceVariant); - rows = rows.push(note_line(ui, t(Key::SettingsAppearanceCornerHint), tone)); - - // ③ 面板透明度(CPU 档强制 1.0) - let forced = state.panel_is_forced(); - let panel_percent = if forced { - format!("100% (forced from {:.2})", 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)." - } else { - "Only the panel/card layer. Floor 0.40; independent of window transparency." - }; - let panel_control = if forced { - slider(Slider::new(ui, panel_fraction(1.0))) - } else { - slider( - Slider::new(ui, panel_fraction(state.panel_alpha)).on_change(|fraction| { - Message::Appearance(Change::PanelAlpha(panel_alpha_from_fraction(fraction))) - }), - ) - }; - rows = rows.push(slider_row( - ui, - "Panel transparency", - panel_percent, - panel_hint.to_owned(), - !forced, - panel_control, - )); - - // ④ 背景图开关 - rows = rows.push(choice_row( - ui, - "Background image", - vec![ - choice(ui, "Off", !state.bg_enabled, Change::BgEnabled(false)), - choice(ui, "On", state.bg_enabled, Change::BgEnabled(true)), - ], - )); - - // ⑤ 路径(文本框:受控,输入即生效) - let path_field: Element<'a, Message> = text_field( - ui, - Field::new(&state.bg_path) - .label("Image path") - .placeholder("D:/Pictures/background.png"), - |value| Message::Appearance(Change::BgImage(value)), - ); - rows = rows.push( - column![label(ui, "Image path"), path_field] - .spacing(ui.space(Space::X1)) - .width(Length::Fill), - ); - - // ⑥ 诊断(读盘失败 / CPU 降级 / 配色回落)——**不许静默**,且必须在**首屏可见处** - // (复审 r1 P2-3:文案落在折叠线以下等于没提示)。位置就在背景图那两行的紧下方。 - for note in state.diagnostics() { - rows = rows.push(note_line(ui, note, ui.color(Role::Error))); - } - if let Some(note) = state.backend_note() { - rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant))); - } - if let Some(note) = state - .image() - .and_then(|image| background::small_tile_note(image, state.bg_fill)) - { - rows = rows.push(note_line(ui, ¬e, ui.color(Role::OnSurfaceVariant))); - } - if let Some(note) = state.palette_note() { - rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant))); - } - let resolved = format!("Colours: {}", state.palette_level()); - rows = rows.push(note_line(ui, &resolved, ui.color(Role::OnSurfaceVariant))); - - // ⑦ 填充 - rows = rows.push(choice_row( - ui, - "Fill", - vec![ - choice( - ui, - "Cover", - state.bg_fill == BgFill::Cover, - Change::BgFill(BgFill::Cover), - ), - choice( - ui, - "Contain", - state.bg_fill == BgFill::Contain, - Change::BgFill(BgFill::Contain), - ), - choice( - ui, - "Tile", - state.bg_fill == BgFill::Tile, - Change::BgFill(BgFill::Tile), - ), - ], - )); - - // ⑧ 暗化 - rows = rows.push(slider_row( - ui, - "Dim", - format!("{:.0}%", state.bg_dim * 100.0), - format!( - "Blends the image toward the panel colour so text stays readable. Max {BG_DIM_MAX:.2}." - ), - state.bg_dim > 0.0, - slider( - Slider::new(ui, state.bg_dim / BG_DIM_MAX) - .on_change(|fraction| Message::Appearance(Change::BgDim(fraction * BG_DIM_MAX))), - ), - )); - - // ⑨ 配色来源 - rows = rows.push(choice_row( - ui, - "Colour source", - vec![ - choice( - ui, - "Background", - state.palette_source == PaletteSource::BackgroundImage, - Change::PaletteSource(PaletteSource::BackgroundImage), - ), - choice( - ui, - "Wallpaper", - state.palette_source == PaletteSource::Wallpaper, - Change::PaletteSource(PaletteSource::Wallpaper), - ), - choice( - ui, - "Preset", - state.palette_source == PaletteSource::Preset, - Change::PaletteSource(PaletteSource::Preset), - ), - ], - )); - - card(ui, rows) -} - -/// 外观卡片:底色走 `ui.layer`(面板 α 的唯一来源),与其它页的卡片同源。 -fn card<'a>(ui: &'a Ui, content: impl Into>) -> Element<'a, Message> { - let spec = Component::OutlinedCard.spec(); - let background = ui.layer(MaterialLayer::Card); - - container(content) - .width(Length::Fill) - .padding(ui.metrics().card_padding) - .style(move |_theme| container::Style { - background: Some(Background::Color(background)), - border: ui.outlined(spec.shape), - ..container::Style::default() - }) - .into() -} - -// --------------------------------------------------------------- 行与控件 - -fn label<'a>(ui: &'a Ui, content: &'a str) -> Element<'a, Message> { - frame::text_role( - ui, - TypeRole::BodyMedium, - false, - content, - ui.color(Role::OnSurface), - ) -} - -/// 设置页文案的 i18n 取词口(M116 起新条目一律走词表,不再写字面量)。 -/// -/// 为什么是 [`Lang::En`]:本文件的既有文案是英文基线(i18n 接线是另一条 mission, -/// 见本文件头部「文案一律英文基线」)。走词表的意义是**新增条目已经有中英两套**, -/// 接线时只换这一行,不用回来重写控件。 -fn t(key: Key) -> &'static str { - unlucky_i18n::Lang::En.catalog().text(key) -} - -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) -} - -/// 一行「标签 + 值 + 滑块 + 说明」。 -fn slider_row<'a>( - ui: &'a Ui, - title: &'a str, - value: String, - hint: String, - enabled: bool, - control: Element<'a, Message>, -) -> Element<'a, Message> { - let value_color = if enabled { - ui.color(Role::OnSurface) - } else { - ui.color(Role::OnSurfaceVariant) - }; - - let head = row![ - container(label(ui, title)) - .width(Length::Fill) - .align_y(Alignment::Center), - frame::text_role_owned(ui, TypeRole::LabelLarge, true, value, value_color), - ] - .width(Length::Fill) - .align_y(Alignment::Center); - - column![ - head, - control, - frame::text_role_owned( - ui, - TypeRole::BodySmall, - false, - hint, - ui.color(Role::OnSurfaceVariant), - ), - ] - .spacing(ui.space(Space::X1)) - .width(Length::Fill) - .padding(Padding::from([ui.space(Space::X2), 0.0])) - .into() -} - -/// 一行「标签 + 一排互斥按钮」。 -fn choice_row<'a>( - ui: &'a Ui, - title: &'a str, - choices: Vec>, -) -> Element<'a, Message> { - row![ - container(label(ui, title)) - .width(Length::Fill) - .align_y(Alignment::Center), - row(choices) - .spacing(ui.space(Space::X1)) - .align_y(Alignment::Center), - ] - .width(Length::Fill) - .align_y(Alignment::Center) - .padding(Padding::from([ui.space(Space::X2), 0.0])) - .into() -} - -/// 一个互斥按钮(选中态用 `SecondaryContainer`,未选中用透明 + `OnSurfaceVariant`)。 -fn choice<'a>(ui: &'a Ui, text: &'a str, selected: bool, change: Change) -> Element<'a, Message> { - let height = ui.metrics().control_height; - let content_color = if selected { - ui.color(Role::OnSecondaryContainer) - } else { - ui.color(Role::OnSurfaceVariant) - }; - - button( - container(frame::text_role( - ui, - TypeRole::LabelLarge, - selected, - text, - content_color, - )) - .align_y(Alignment::Center), - ) - .height(height) - .padding(Padding::from([0.0, ui.space(Space::X3)])) - .style(move |_theme: &IcedTheme, status| { - let background = if selected { - Some(Background::Color(ui.color(Role::SecondaryContainer))) - } else if matches!(status, button::Status::Hovered | button::Status::Pressed) { - Some(Background::Color(ui.state_over( - unlucky_design::color::StateLayer::Hover, - Role::OnSurface, - Role::Surface, - ))) - } else { - None - }; - - button::Style { - background, - text_color: content_color, - border: Border { - radius: ui.radius(ShapeScale::Full, height), - ..Border::default() - }, - shadow: Shadow::default(), - snap: true, - } - }) - .on_press(Message::Appearance(change)) - .into() -} diff --git a/crates/app-iced/src/pages/appearance/section/bg_image.rs b/crates/app-iced/src/pages/appearance/section/bg_image.rs new file mode 100644 index 0000000..0c954e5 --- /dev/null +++ b/crates/app-iced/src/pages/appearance/section/bg_image.rs @@ -0,0 +1,109 @@ +//! 外观分组第 ④ ⑤ ⑥ ⑦ ⑧ 段:**背景图**——开关、路径、诊断、填充档、暗化强度。 +//! +//! 这五段挤在一起是因为它们读同一份 `bg_*` 状态,而且**诊断必须紧贴控件**(复审 r1 P2-3: +//! 读盘失败 / CPU 降级 / 配色回落这些提示落在折叠线以下等于没提示)。 + +use iced::widget::column; +use iced::{Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::spacing::Space; +use unlucky_presenter::prefs::BgFill; + +use crate::app::components::inputs::{slider, text_field, Field, Slider}; +use crate::pages::Message; +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}; + +/// 把 ④ 开关、⑤ 路径、⑥ 诊断、⑦ 填充、⑧ 暗化按视觉顺序推进 `rows`。 +pub(super) fn push<'a>(ui: &'a Ui, state: &'a State, rows: &mut Vec>) { + // ④ 背景图开关 + rows.push(choice_row( + ui, + "Background image", + vec![ + choice(ui, "Off", !state.bg_enabled, Change::BgEnabled(false)), + choice(ui, "On", state.bg_enabled, Change::BgEnabled(true)), + ], + )); + + // ⑤ 路径(文本框:受控,输入即生效) + let path_field: Element<'a, Message> = text_field( + ui, + Field::new(&state.bg_path) + .label("Image path") + .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(), + ); + + // ⑥ 诊断(读盘失败 / CPU 降级 / 配色回落)——**不许静默**,且必须在**首屏可见处** + // (复审 r1 P2-3:文案落在折叠线以下等于没提示)。位置就在背景图那两行的紧下方。 + for note in state.diagnostics() { + rows.push(note_line(ui, note, ui.color(Role::Error))); + } + if let Some(note) = state.backend_note() { + rows.push(note_line(ui, note, 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))); + } + if let Some(note) = state.palette_note() { + rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant))); + } + let resolved = format!("Colours: {}", state.palette_level()); + rows.push(note_line(ui, &resolved, ui.color(Role::OnSurfaceVariant))); + + // ⑦ 填充 + rows.push(choice_row( + ui, + "Fill", + vec![ + choice( + ui, + "Cover", + state.bg_fill == BgFill::Cover, + Change::BgFill(BgFill::Cover), + ), + choice( + ui, + "Contain", + state.bg_fill == BgFill::Contain, + Change::BgFill(BgFill::Contain), + ), + choice( + ui, + "Tile", + state.bg_fill == BgFill::Tile, + Change::BgFill(BgFill::Tile), + ), + ], + )); + + // ⑧ 暗化 + rows.push(slider_row( + ui, + "Dim", + format!("{:.0}%", state.bg_dim * 100.0), + format!( + "Blends the image toward the panel colour so text stays readable. Max {BG_DIM_MAX:.2}." + ), + state.bg_dim > 0.0, + slider( + Slider::new(ui, state.bg_dim / BG_DIM_MAX) + .on_change(|fraction| Message::Appearance(Change::BgDim(fraction * BG_DIM_MAX))), + ), + )); +} diff --git a/crates/app-iced/src/pages/appearance/section/colour_source.rs b/crates/app-iced/src/pages/appearance/section/colour_source.rs new file mode 100644 index 0000000..e79a2b9 --- /dev/null +++ b/crates/app-iced/src/pages/appearance/section/colour_source.rs @@ -0,0 +1,41 @@ +//! 外观分组第 ⑨ 段:**配色来源**(背景图 / 壁纸 / 色卡三档;它选的正是 D38 三级优先序的起点)。 + +use iced::Element; + +use unlucky_presenter::prefs::PaletteSource; + +use crate::pages::Message; +use crate::theme::Ui; + +use super::super::state::State; +use super::super::Change; +use super::{choice, choice_row}; + +/// 把 ⑨ 配色来源推进行 `rows`。 +pub(super) fn push<'a>(ui: &'a Ui, state: &'a State, rows: &mut Vec>) { + // ⑨ 配色来源 + rows.push(choice_row( + ui, + "Colour source", + vec![ + choice( + ui, + "Background", + state.palette_source == PaletteSource::BackgroundImage, + Change::PaletteSource(PaletteSource::BackgroundImage), + ), + choice( + ui, + "Wallpaper", + state.palette_source == PaletteSource::Wallpaper, + Change::PaletteSource(PaletteSource::Wallpaper), + ), + choice( + ui, + "Preset", + 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 new file mode 100644 index 0000000..e6aa711 --- /dev/null +++ b/crates/app-iced/src/pages/appearance/section/mod.rs @@ -0,0 +1,258 @@ +//! 设置页「外观」分组(**真控件**)+ 背景层 Element + 面板层配色。 +//! +//! ## 控件与键的对应 +//! +//! | 控件 | 键 | 改动消息 | +//! |---|---|---| +//! | 整窗透明度滑块(下限 0.08) | `appearance.window_opacity` | [`Change::WindowOpacity`] | +//! | 窗口圆角(跟随系统 / 直角,M116) | `appearance.window_corner` | [`Change::Corner`] | +//! | 面板透明度滑块(下限 0.4,CPU 档禁用) | `appearance.panel_alpha` | [`Change::PanelAlpha`] | +//! | 背景图开关 | `appearance.bg_image_enabled` | [`Change::BgEnabled`] | +//! | 背景图路径(文本框) | `appearance.bg_image` | [`Change::BgImage`] | +//! | 填充(封面 / 拉伸 / 平铺) | `appearance.bg_fill` | [`Change::BgFill`] | +//! | 暗化强度(0~0.95) | `appearance.bg_dim` | [`Change::BgDim`] | +//! | 配色来源(三档) | `appearance.palette_source` | [`Change::PaletteSource`] | +//! +//! ## 文件分工(M179 `I18NSEAM`:纯搬移 + 语言接线) +//! +//! 本模块原来是一个 395 行的 `section.rs`,而全仓行数守卫是 400(`components/gate.rs`), +//! 剩 5 行——加不了任何东西。M179 按「一段控件一个文件」切开了它: +//! +//! | 文件 | 是什么 | +//! |---|---| +//! | 本文件(`section/mod.rs`) | **壳**:[`section`] 按顺序拼各段 + [`card`] 卡片 + 行控件([`label`] / [`slider_row`] / [`choice_row`] / [`choice`] / [`note_line`])+ 取词口 [`t`] | +//! | [`window`] | ① 整窗透明度 + ② 窗口圆角(含「因整窗 α 不生效」的 notice 与说明段) | +//! | [`panel`] | ③ 面板透明度(CPU 档强制 1.0,值 / 生效值分开显示) | +//! | [`bg_image`] | ④ 背景图开关 + ⑤ 路径 + ⑥ 诊断 + ⑦ 填充 + ⑧ 暗化 | +//! | [`colour_source`] | ⑨ 配色来源(三档) | +//! +//! 拆分口径:**纯搬移**——每段的语句逐字搬过去,只有下面这几处机械改写(M179 报告里用「去空白/ +//! 去尾逗号 diff」逐段证明:改完之后与原文件逐字相同,断言一条没动): +//! +//! 1. `rows = rows.push(…)` → `rows.push(…)`(壳现在传 `&mut Vec`); +//! 2. `t(Key::X)` → `t(Key::X, lang)`(本 mission 的语言接线); +//! 3. `super::notice(…)` → `super::super::notice(…)`(多了一层模块); +//! 4. 唯一一处非 `Element` 实参:`rows.push(column![…])` → 末尾加 `.into()`(`Vec::push` 不做 `Into`); +//! 5. rustfmt 因为多出来的 `, lang` 把两处调用换成了多行。 +//! +//! ## 语言(M179 只接管子,不补文案) +//! +//! [`t`] 是**唯一**取词口,现在收 `lang`(真值从 `settings::view` 的 `Language.effective` +//! → `Group.body` → [`section`] 一路递进来),所以中文界面上走词表的那 5 处(窗口圆角一行、 +//! 两个选项、说明段、notice)真的是中文。**其余文案仍是英文基线**(字面量,没有 key)—— +//! 补文案是下一条 mission,M179 只负责让「管子」通到这一层。 +//! +//! ## 为什么「值」与「生效值」分开显示 +//! +//! CPU 档下面板 α 被强制 1.0(D36),而用户请求值可能是 0.5。滑块位置按**生效值**画、旁边写 +//! 「requested 0.50 → forced 1.00」,这样「为什么拖了没反应」在界面上就有答案,而不是只躺在日志里。 + +use iced::widget::{button, column, container, row}; +use iced::{ + Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme as IcedTheme, +}; + +use unlucky_design::color::Role; +use unlucky_design::component_map::Component; +use unlucky_design::elevation::MaterialLayer; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; +use unlucky_i18n::catalog::Key; +use unlucky_i18n::Lang; + +use crate::pages::{frame, Message}; +use crate::theme::Ui; + +use super::state::State; +use super::Change; + +mod bg_image; +mod colour_source; +mod panel; +mod window; + +/// 「外观」分组整块(一张卡)。 +/// +/// `lang` 是**这一次生效的语言**:`settings::view` 的 `Language.effective`(M179)。壳把各段按 +/// **视觉顺序**拼起来;每一段的语句逐字来自拆分前的 `section()`。 +pub fn section<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> { + 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); + + card( + ui, + column(rows) + .spacing(ui.space(Space::X1)) + .width(Length::Fill), + ) +} + +/// 外观卡片:底色走 `ui.layer`(面板 α 的唯一来源),与其它页的卡片同源。 +fn card<'a>(ui: &'a Ui, content: impl Into>) -> Element<'a, Message> { + let spec = Component::OutlinedCard.spec(); + let background = ui.layer(MaterialLayer::Card); + + container(content) + .width(Length::Fill) + .padding(ui.metrics().card_padding) + .style(move |_theme| container::Style { + background: Some(Background::Color(background)), + border: ui.outlined(spec.shape), + ..container::Style::default() + }) + .into() +} + +// --------------------------------------------------------------- 行与控件 +// +// 五个行控件由各段共用,所以留在壳里(`pub(super)` = 只给 `section/` 下的兄弟模块用)。 + +pub(super) fn label<'a>(ui: &'a Ui, content: &'a str) -> Element<'a, Message> { + frame::text_role( + ui, + TypeRole::BodyMedium, + false, + content, + ui.color(Role::OnSurface), + ) +} + +/// 设置页文案的 i18n 取词口(M116 起新条目一律走词表,不再写字面量)。 +/// +/// `lang` 是**这一次生效的语言**,由 [`section`] 一路递下来(M179 `I18NSEAM` 之前这里按英文目录 +/// 取词,于是中文界面上本分组的 27 条文案里真正本地化 0 条)。现在这个函数是全模块 +/// **唯一**的取词点:谁硬编码英文,就不会经过这里——M179 报告里逐行列了那些字面量,补文案是 +/// 下一条 mission。 +pub(super) fn t(key: Key, lang: Lang) -> &'static str { + let text = lang.catalog().text(key); + // 测试专用:把「这一帧真的取了哪条译文」记一份(iced 0.14 的 `Element` 没有可遍历控件树的 + // 公开接口,所以「渲染出来的字是不是中文」只能在取词口取证,见 `appearance::probe`)。 + #[cfg(test)] + super::probe::record(key, text); + text +} + +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) +} + +/// 一行「标签 + 值 + 滑块 + 说明」。 +pub(super) fn slider_row<'a>( + ui: &'a Ui, + title: &'a str, + value: String, + hint: String, + enabled: bool, + control: Element<'a, Message>, +) -> Element<'a, Message> { + let value_color = if enabled { + ui.color(Role::OnSurface) + } else { + ui.color(Role::OnSurfaceVariant) + }; + + let head = row![ + container(label(ui, title)) + .width(Length::Fill) + .align_y(Alignment::Center), + frame::text_role_owned(ui, TypeRole::LabelLarge, true, value, value_color), + ] + .width(Length::Fill) + .align_y(Alignment::Center); + + column![ + head, + control, + frame::text_role_owned( + ui, + TypeRole::BodySmall, + false, + hint, + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill) + .padding(Padding::from([ui.space(Space::X2), 0.0])) + .into() +} + +/// 一行「标签 + 一排互斥按钮」。 +pub(super) fn choice_row<'a>( + ui: &'a Ui, + title: &'a str, + choices: Vec>, +) -> Element<'a, Message> { + row![ + container(label(ui, title)) + .width(Length::Fill) + .align_y(Alignment::Center), + row(choices) + .spacing(ui.space(Space::X1)) + .align_y(Alignment::Center), + ] + .width(Length::Fill) + .align_y(Alignment::Center) + .padding(Padding::from([ui.space(Space::X2), 0.0])) + .into() +} + +/// 一个互斥按钮(选中态用 `SecondaryContainer`,未选中用透明 + `OnSurfaceVariant`)。 +pub(super) fn choice<'a>( + ui: &'a Ui, + text: &'a str, + selected: bool, + change: Change, +) -> Element<'a, Message> { + let height = ui.metrics().control_height; + let content_color = if selected { + ui.color(Role::OnSecondaryContainer) + } else { + ui.color(Role::OnSurfaceVariant) + }; + + button( + container(frame::text_role( + ui, + TypeRole::LabelLarge, + selected, + text, + content_color, + )) + .align_y(Alignment::Center), + ) + .height(height) + .padding(Padding::from([0.0, ui.space(Space::X3)])) + .style(move |_theme: &IcedTheme, status| { + let background = if selected { + Some(Background::Color(ui.color(Role::SecondaryContainer))) + } else if matches!(status, button::Status::Hovered | button::Status::Pressed) { + Some(Background::Color(ui.state_over( + unlucky_design::color::StateLayer::Hover, + Role::OnSurface, + Role::Surface, + ))) + } else { + None + }; + + button::Style { + background, + text_color: content_color, + border: Border { + radius: ui.radius(ShapeScale::Full, height), + ..Border::default() + }, + shadow: Shadow::default(), + snap: true, + } + }) + .on_press(Message::Appearance(change)) + .into() +} diff --git a/crates/app-iced/src/pages/appearance/section/panel.rs b/crates/app-iced/src/pages/appearance/section/panel.rs new file mode 100644 index 0000000..58661ba --- /dev/null +++ b/crates/app-iced/src/pages/appearance/section/panel.rs @@ -0,0 +1,45 @@ +//! 外观分组第 ③ 段:**面板透明度**滑块(下限 0.4;CPU 档强制 1.0 并禁用滑块)。 + +use iced::Element; + +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::state::State; +use super::super::Change; +use super::slider_row; + +/// 把 ③ 面板透明度推进 `rows`。 +pub(super) fn push<'a>(ui: &'a Ui, state: &'a State, 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) + } else { + format!("{:.0}%", state.effective_panel_alpha() * 100.0) + }; + let panel_hint = if forced { + "Software renderer: panels stay fully opaque (window transparency still works)." + } else { + "Only the panel/card layer. Floor 0.40; independent of window transparency." + }; + let panel_control = if forced { + slider(Slider::new(ui, panel_fraction(1.0))) + } else { + slider( + Slider::new(ui, panel_fraction(state.panel_alpha)).on_change(|fraction| { + Message::Appearance(Change::PanelAlpha(panel_alpha_from_fraction(fraction))) + }), + ) + }; + rows.push(slider_row( + ui, + "Panel transparency", + panel_percent, + panel_hint.to_owned(), + !forced, + panel_control, + )); +} diff --git a/crates/app-iced/src/pages/appearance/section/window.rs b/crates/app-iced/src/pages/appearance/section/window.rs new file mode 100644 index 0000000..3cd9399 --- /dev/null +++ b/crates/app-iced/src/pages/appearance/section/window.rs @@ -0,0 +1,86 @@ +//! 外观分组第 ① ② 段:**整窗**两件——整窗透明度滑块、窗口圆角(含 notice 与说明段)。 +//! +//! 这两段是唯一**一个控件牵动另一个控件**的地方:圆角「跟随系统」时把整窗透明度调低会让圆角失效 +//! (`DwmExtendFrameIntoClientArea` 的世界里没有半透明边框),所以只有这一对放在同一个文件里—— +//! notice 的判据(`corner == System && window_opacity < 1.0`)跨这两段。 + +use iced::widget::space; +use iced::Element; + +use unlucky_design::color::Role; +use unlucky_design::spacing::Space; +use unlucky_i18n::catalog::Key; +use unlucky_i18n::Lang; +use unlucky_presenter::prefs::WindowCorner; + +use crate::app::components::inputs::{slider, Slider}; +use crate::pages::Message; +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}; + +/// 把 ① 整窗透明度与 ② 窗口圆角按视觉顺序推进 `rows`。 +pub(super) fn push<'a>( + ui: &'a Ui, + state: &'a State, + lang: Lang, + rows: &mut Vec>, +) { + // ① 整窗透明度 + let window_percent = format!("{:.0}%", state.window_opacity * 100.0); + rows.push(slider_row( + ui, + "Window transparency", + window_percent, + format!( + "Applies to the whole window. Floor {:.2}; 1.00 = fully opaque.", + WINDOW_ALPHA_FLOOR + ), + state.window_opacity < 1.0, + slider( + Slider::new(ui, window_fraction(state.window_opacity)).on_change(|fraction| { + Message::Appearance(Change::WindowOpacity(window_alpha_from_fraction(fraction))) + }), + ), + )); + + // ② 窗口圆角(M116):只有两档 + 一句按平台说明;不做 8/12/16 档(系统不暴露半径)。 + let corner = state.window_corner(); + rows.push(choice_row( + ui, + t(Key::SettingsAppearanceCorner, lang), + vec![ + choice( + ui, + t(Key::SettingsAppearanceCornerSystem, lang), + corner == WindowCorner::System, + Change::Corner(WindowCorner::System), + ), + choice( + ui, + t(Key::SettingsAppearanceCornerSquare, lang), + corner == WindowCorner::Square, + Change::Corner(WindowCorner::Square), + ), + ], + )); + // **tonal notice**(r5 视觉终稿):M3 里表达「当前状态」的惯用形态是**容器**,不是改字色。 + // 只在「跟随系统 + α<1」出现;与下面的说明段之间再留 `Space::X1`(列间距 X1 + 这里 X1 = X2)。 + if corner == WindowCorner::System && state.window_opacity < 1.0 { + rows.push(super::super::notice( + ui, + t(Key::SettingsAppearanceCornerSuspended, lang), + )); + let gap: Element<'a, Message> = space::vertical().height(ui.space(Space::X1)).into(); + rows.push(gap); + } + let tone = ui.color(Role::OnSurfaceVariant); + rows.push(note_line( + ui, + t(Key::SettingsAppearanceCornerHint, lang), + tone, + )); +} diff --git a/crates/app-iced/src/pages/settings/appearance.rs b/crates/app-iced/src/pages/settings/appearance.rs index 6d98023..ba2214c 100644 --- a/crates/app-iced/src/pages/settings/appearance.rs +++ b/crates/app-iced/src/pages/settings/appearance.rs @@ -5,20 +5,25 @@ //! 「Appearance」这个名字**;分组在左列的下标仍由 `crate::pages::appearance::APPEARANCE_GROUP` //! 声明,`settings/tests/mod.rs` 拿它和本文件比对(顺序被调换会红)。 //! -//! **这一组归 language+appearance 线**:真控件的实现全在 `pages/appearance/section.rs`(本文件只负责 +//! **这一组归 language+appearance 线**:真控件的实现全在 `pages/appearance/section/`(本文件只负责 //! 把它挂进注册表),所以改控件时不用碰设置页目录。 //! //! M101 r2:本组的渲染器签名改成收 [`States`](组状态 + 外观状态),于是「这一组要读哪些状态」 //! 由本文件决定——`settings/mod.rs` 不再认识 `appearance::State`。 +//! +//! M179 `I18NSEAM`:签名再加一个 `lang`(`Group.body` 的槽位本身就带语言,见 `settings/mod.rs` 的 +//! `Body` 类型别名),于是这一组的中文界面文案真的走词表——此前它只能写死 `Lang::En`, +//! 因为渲染器拿不到「这一次生效的语言」。 use iced::Element; use unlucky_design::icons::IconKind; +use unlucky_i18n::Lang; use crate::theme::Ui; use super::{Group, Message, States}; -/// Appearance 分组。`body` 指着 [`body`](它再转给 `pages/appearance/section.rs` 的真控件); +/// Appearance 分组。`body` 指着 [`body`](它再转给 `pages/appearance/section/` 的真控件); /// 没有外观运行期状态时(harness / `pages_demo`)返回 `None`,由 `form` 退回下面的演示行。 pub(super) const GROUP: Group = Group { name: "Appearance", @@ -33,8 +38,10 @@ pub(super) const GROUP: Group = Group { }; /// 本组的真控件渲染器:有外观状态就画真控件(M93),没有(harness)返回 `None`。 -fn body<'a>(ui: &'a Ui, states: States<'a>) -> Option> { +/// +/// `lang` 一路递给 `pages::appearance::section`——它是这一组唯一知道「界面现在是什么语言」的东西。 +fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Option> { states .appearance - .map(|state| crate::pages::appearance::section(ui, state)) + .map(|state| crate::pages::appearance::section(ui, state, lang)) } diff --git a/crates/app-iced/src/pages/settings/mod.rs b/crates/app-iced/src/pages/settings/mod.rs index e96fcdb..8c86965 100644 --- a/crates/app-iced/src/pages/settings/mod.rs +++ b/crates/app-iced/src/pages/settings/mod.rs @@ -25,7 +25,7 @@ //! | 分组 | 文件(正文 / 状态 / 消息) | 谁会动它 | //! |---|---|---| //! | General | [`general`] | language+appearance 线(语言下拉 + 主题 / 启动页) | -//! | Appearance | [`appearance`] | language+appearance 线(真控件已在 `pages/appearance/section.rs`) | +//! | Appearance | [`appearance`] | language+appearance 线(真控件已在 `pages/appearance/section/`) | //! | Java | [`java`] | java 线 | //! | Downloads | [`download`] | download 线 | //! | Updates | [`update`] | update 线(M159,按下标分发) | @@ -117,7 +117,7 @@ //! 与 M106 同形状),要么读 `Groups` 里本线的字段。两种形状都是「谁先撞谁付一次钱」的一次性代价, //! 不是永久性四路撞车。 //! 5. **首帧取数 / 异步发起**(同上,r2 复审 P1 补)—— [`Body`] 是 -//! `fn(&Ui, States) -> Option`,返回不了 `Task`;而且 `settings::view` → `pages::render` +//! `fn(&Ui, States, Lang) -> Option`,返回不了 `Task`;而且 `settings::view` → `pages::render` //! → `App::view` **一路都返回 `Element`**(iced 0.14 的 `view` 也只返回 `Element`,`view(&self)` //! 又改不了状态),**view 期间产出的 `Task` 没有出口**。所以只有一条路:在 `app/update.rs` 的 //! `Message::Boot` 分支补一次首帧派发(**1 个共享文件**——`on_boot` 本来就在派 @@ -193,13 +193,16 @@ impl Default for Language { } } -/// 一个分组正文的**真控件渲染器**:入参是「画一帧时那份只读状态」(组状态 + 外观状态), -/// 返回 `None` = 这一组现在没东西可画(例如外观分组在 harness 路径下没有外观状态), -/// 由 [`form`] 退回演示行。 +/// 一个分组正文的**真控件渲染器**:入参是「画一帧时那份只读状态」(组状态 + 外观状态) +/// 与**这一次生效的语言**,返回 `None` = 这一组现在没东西可画(例如外观分组在 harness 路径下 +/// 没有外观状态),由 [`form`] 退回演示行。 /// /// 各组自己的渲染器**定义在自己文件里**,签名是这一条:于是「这一组要读哪些状态」由它自己决定, /// 本文件不需要认识任何一组的字段(r1 复审 P1 要的正是这一点)。 -type Body = for<'a> fn(&'a Ui, States<'a>) -> Option>; +/// +/// `lang` 是 M179 加的:槽位此前拿不到语言,唯一挂了真控件的外观组只能写死英文;接进**唯一的那个 +/// 槽位**比再给 [`form`] 加一条按下标分发的 `else if` 更省行数(本文件贴着 400 行硬顶)。 +type Body = for<'a> fn(&'a Ui, States<'a>, Lang) -> Option>; /// 一个设置分组:名字、图标、演示行、(可选)把这一组正文画成真控件的渲染器。 /// @@ -236,9 +239,9 @@ const SELECTED: usize = 0; /// General 分组在 [`GROUPS`] 里的下标(0)。它与 [`SELECTED`] 同值**是巧合,不是依赖**。 /// -/// 为什么需要它:语言行要的是「当前档位 + 生效语言」([`Language`]),而 `Group.body` 的签名只带 -/// [`States`](外观 + 组状态)——给 `States` 加字段要动 `settings/state.rs` 与 4 处 struct literal -/// (见本文件文档「仍然共享」第 4 条),给 `Body` 加形参会令另外三组各自的 `body` 函数编译不过。 +/// 为什么需要它:语言行要的是「当前档位 + 生效语言」([`Language`]),而 `Group.body` 只带 +/// [`States`] 与**生效语言**(`Lang`,M179)——档位 `LangChoice` 装不进去(`States` 也装不下: +/// 加字段要动 `settings/state.rs` 与 4 处 struct literal,见本文件文档「仍然共享」第 4 条)。 /// 所以这里做一次**按下标的分发**:代价是一行 `if`,换来 `general::GROUP.body` 仍是 `None` /// (`tests/mod.rs` 的「只有外观组带真控件 body」照样成立)。 const GENERAL_GROUP: usize = 0; @@ -315,8 +318,8 @@ pub fn view<'a>( /// /// **分发点(两条路,M106 起就并存)**: /// -/// 1. `GROUP.body`——`Some(f)` 让 `f` 自己去 [`States`] 里取它要的数据(`None` 返回值 ⇒ 退回 -/// 演示行),`None` 直接画演示行([`frame::demo_card`]); +/// 1. `GROUP.body`——`Some(f)` 让 `f` 自己去 [`States`] 里取它要的数据、并从形参拿**生效语言** +/// (`None` 返回值 ⇒ 退回演示行),`None` 直接画演示行([`frame::demo_card`]); /// 2. **按下标分发**——General(要语言档位)、Update(要 presenter 快照)、Assistant(要 `Lang` /// 与测试结论)、Java(要 `Lang` 与扫描槽)四组在下面各有 `else if`,它们的 `GROUP.body` /// 仍是 `None`(理由见各自的 `<组>_GROUP` 常量)。 @@ -357,7 +360,7 @@ fn form<'a>( } else { group .body - .and_then(|body| body(ui, states)) + .and_then(|body| body(ui, states, language.effective)) .unwrap_or_else(|| frame::demo_card(ui, group.rows)) }; diff --git a/crates/app-iced/src/pages/settings/tests/appearance_section.rs b/crates/app-iced/src/pages/settings/tests/appearance_section.rs index e318042..2fe858e 100644 --- a/crates/app-iced/src/pages/settings/tests/appearance_section.rs +++ b/crates/app-iced/src/pages/settings/tests/appearance_section.rs @@ -2,12 +2,25 @@ //! 一条 section 断言都没有;这 3 条是新增,不是搬移)。 //! //! **归 language+appearance 线**:这是设置页唯一接了真控件的一组,真控件实现全在 -//! `pages/appearance/section.rs`;本文件只守「注册表这一侧的接线」。 +//! `pages/appearance/section/`;本文件只守「注册表这一侧的接线」。 //! 本组已经把 `body` 接上了;`tests/mod.rs` 那条「只有一组有真控件」的**聚合绊线已在 M101 r2 删除** //! (被四条 per-group 断言完全蕴含——代价是「唯一性」从此没人守,那段说明写在 `tests/mod.rs` 同处)。 +//! +//! M179 `I18NSEAM` 加的第 4 条守的是**语言真的进到了渲染层**(此前这一组在中文界面上 0/27 条本地化)。 use super::super::appearance; +use super::super::{demo_groups, view, Language, States}; +use unlucky_design::color::SchemeFlavor; use unlucky_design::icons::IconKind; +use unlucky_i18n::catalog::Key; +use unlucky_i18n::{Lang, LangChoice}; +use unlucky_presenter::prefs::{AppearanceBase, AppearancePrefs}; + +use crate::cli::Backend; +use crate::pages::appearance::{probe, State as AppearanceState}; +use crate::pages::layout::{frame_metrics, PaneKit}; +use crate::pages::Update; +use crate::theme::{Families, Ui}; /// 这一组的**演示行**(没有外观运行期状态时才画)。 /// @@ -50,3 +63,109 @@ fn the_appearance_group_keeps_a_real_control_body() { `body` 是本文件那个本地 fn;具体渲染器由那一行源码 + 截图担保,函数指针相等性断言不了)" ); } + +// --------------------------------------------------------------- 语言(M179 I18NSEAM) + +/// **本组的语言绊线**:中文界面下,外观分组渲染出来的字里**必须含汉字**。 +/// +/// 走的是真路:`settings::view`(真语言分发)→ `Group.body(lang)` → `pages::appearance::section` +/// → `section::t(key, lang)`;取词口 `t` 把每条译文记进 `pages::appearance::probe`,测试断言这批字。 +/// +/// 为什么不在 `Element` 上遍历:iced 0.14 **没有**可遍历控件树的公开接口(`Widget::children()` +/// 返回的是状态 `Tree`;走 `operate` 要 `Tree` + 布局 + `Renderer`,单测里没有渲染器)。取证点 +/// 因此退到「字进树之前」的唯一取词口,代价是**看不见像素**——字体能不能画出汉字要截图判 +/// (M179 报告「没验的部分」列了这条)。 +/// +/// 抓得住:① `section::t` 退回写死 `Lang::En`(M179 之前正是这一行 ⇒ 中文界面上这一组 0/27 +/// 条本地化);② `settings::view` 不把 `language.effective` 递给 `Group.body`(语言到不了渲染层); +/// ③ 五个 key 的译文在金表里丢了中文。 +/// 抓不住:本组其余文案仍是英文**字面量**(没有 key,M172 口径的 27 条里占 22 条)——这条守的是 +/// 语言的**管子**,不是文案;补文案是下一条 mission(M179 报告逐行列了那些字面量)。 +#[test] +fn the_appearance_group_speaks_chinese_when_the_interface_does() { + let zh = appearance_texts(Lang::ZhCn); + assert!( + !zh.is_empty(), + "外观分组一条词表文案都没取到:渲染路径没走到 `section::t`(语言管子断了?)" + ); + assert!( + zh.iter() + .any(|(key, _)| *key == Key::SettingsAppearanceCorner), + "渲染路径没画到「窗口圆角」那一行:取词记录里没有它的 key,{zh:?}" + ); + + let han: Vec<&str> = zh + .iter() + .map(|(_, text)| *text) + .filter(|text| text.chars().any(is_han)) + .collect(); + assert!( + !han.is_empty(), + "中文界面下外观分组渲染出来的字全是非汉字(`section::t` 没跟随语言):{zh:?}" + ); + + // 反向:英文界面下同一批字里不许出现汉字(否则「含汉字」可能来自写死的中文,而不是语言管子)。 + let en = appearance_texts(Lang::En); + assert!( + en.iter().all(|(_, text)| !text.chars().any(is_han)), + "英文界面下外观分组出现了汉字:{en:?}" + ); + let corner = |texts: &[(Key, &'static str)]| { + texts + .iter() + .find(|(key, _)| *key == Key::SettingsAppearanceCorner) + .map(|(_, text)| *text) + }; + assert_ne!( + corner(&zh), + corner(&en), + "同一个 key 在中英两种语言下取到了同一条译文:语言没生效" + ); +} + +/// 画一帧设置页(选中「外观」分组)并取走这一帧从词表取到的全部译文。 +fn appearance_texts(lang: Lang) -> Vec<(Key, &'static str)> { + const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }; + + let ui = Ui::new(SchemeFlavor::Light, FAMILIES); + let state = AppearanceState::new( + &AppearancePrefs::factory(), + &AppearanceBase::factory(), + Backend::Auto, + SchemeFlavor::Light, + ); + assert_eq!( + state.selected_group, + crate::pages::appearance::APPEARANCE_GROUP, + "工厂态默认选中「外观」分组(否则这一帧根本画不到本组)" + ); + // 前面几条用例不取词,这里先清一次,避免把别的渲染留下的记录算进来。 + probe::take(); + + let metrics = frame_metrics(PaneKit::Supporting, 1180.0); + let update = Update::default(); + let _tree = view( + &ui, + metrics, + States { + appearance: Some(&state), + groups: demo_groups(), + }, + Language { + choice: LangChoice::System, + effective: lang, + }, + &update, + ); + + probe::take() +} + +/// 有没有汉字(判据与 `app/view/tests/launch.rs` 的 `is_cjk` 同一段码位)。 +fn is_han(ch: char) -> bool { + ('\u{4e00}'..='\u{9fff}').contains(&ch) +} diff --git a/crates/app-iced/src/pages/settings/tests/general_section.rs b/crates/app-iced/src/pages/settings/tests/general_section.rs index 5a49fef..702a9e0 100644 --- a/crates/app-iced/src/pages/settings/tests/general_section.rs +++ b/crates/app-iced/src/pages/settings/tests/general_section.rs @@ -8,8 +8,8 @@ //! 假值不许回来,语言行的三档必须真的能在两套目录里取到词。 //! //! 接线形状:语言行的正文由 [`general::body`] 画(入口是 `pages::settings` 的 `GENERAL_GROUP` -//! 按下标分发),`GROUP.body` 仍是 `None`——那个槽位的签名是 `fn(&Ui, States) -> Option`, -//! `States` 只有 `appearance` + `groups` 两格,装不下「当前语言档位」。所以 M101 r2 的 +//! 按下标分发),`GROUP.body` 仍是 `None`——那个槽位现在带**生效语言**(`Lang`,M179)但仍是 +//! `States` + 语言,装不下「当前语言档位」(`LangChoice`)。所以 M101 r2 的 //! 「本组接真控件只需改两个文件」这条口径,在语言行上**仍然是两个文件**。 use super::super::general; @@ -187,8 +187,8 @@ fn the_language_row_offers_three_choices_in_both_languages() { /// **绊线**:这一组仍不走 `Group.body`。 /// /// 为什么不是「接了真控件」:语言行的入口是 `pages::Language`(`GENERAL_GROUP` 那条分发), -/// 而 `Group.body` 那个槽位的签名是 `fn(&Ui, &pages::appearance::State)`——装不下「当前语言档位」。 -/// 谁把语言行塞进 `body`,就得先给它换签名(那会改到另外三组的文件),这条断言逼他先想一遍。 +/// 而 `Group.body` 那个槽位只拿到 `States` 与**生效语言**(`Lang`,M179)——装不下「当前语言档位」。 +/// 谁把语言行塞进 `body`,就得先给它换签名(那会改到别人那个组的文件),这条断言逼他先想一遍。 #[test] fn the_general_group_does_not_use_the_appearance_body_slot() { assert!( diff --git a/crates/app-iced/src/pages/settings/tests/java_section.rs b/crates/app-iced/src/pages/settings/tests/java_section.rs index 18ca878..8e6d8c7 100644 --- a/crates/app-iced/src/pages/settings/tests/java_section.rs +++ b/crates/app-iced/src/pages/settings/tests/java_section.rs @@ -40,7 +40,8 @@ fn the_java_group_keeps_the_folder_icon_from_the_visual_review() { /// `body` 槽位仍是 `None`:本组靠 `settings/mod.rs` 的按下标分发([`java::JAVA_GROUP`])画正文。 /// -/// 抓得住:谁把 `body: Some(..)` 挂上来却不给它 `Lang`(那样中文界面上这一组会退回英文)。 +/// 抓得住:谁把 `body: Some(..)` 挂上来(M179 之后槽位自带 `Lang`,所以「挂了 body 却没语言」 +/// 这种漂移不再可能——挂上来就必然拿到语言;这条守的是**分发形状**别变)。 /// 抓不住:分发那一行本身有没有被删(`settings/mod.rs` 的 `form` 里有 `else if`;删了这组就空白)。 #[test] fn the_java_group_is_dispatched_by_index_not_by_body() { diff --git a/crates/app-iced/src/pages/settings/tests/mod.rs b/crates/app-iced/src/pages/settings/tests/mod.rs index 57c7965..f9a9260 100644 --- a/crates/app-iced/src/pages/settings/tests/mod.rs +++ b/crates/app-iced/src/pages/settings/tests/mod.rs @@ -163,9 +163,10 @@ fn the_split_kept_the_left_column_order() { /// 抓不住:各组的**字段**(那些活在各组自己的文件里,本文件本来就不该知道,也就没有可断言的东西)。 /// /// **M106 的例外要读清楚**:General 已经接了真控件(语言行),但它**不走 `Group.body`**—— -/// 那个槽位的签名是 `fn(&Ui, States) -> Option`,`States` 只有 `appearance` + `groups` -/// 两格,装不下「当前语言档位 + 生效语言」。它走 `settings::view` 的 `language` 形参 + -/// `GENERAL_GROUP` 那条按下标分发,`body` 仍是 `None`,所以本断言不动。 +/// 那个槽位的签名是 `fn(&Ui, States, Lang) -> Option`(`Lang` 是 M179 加的), +/// `States` 只有 `appearance` + `groups` 两格、槽位只有**生效语言**,都装不下「当前语言档位 + +/// 生效语言」这一对。它走 `settings::view` 的 `language` 形参 + `GENERAL_GROUP` 那条按下标分发, +/// `body` 仍是 `None`,所以本断言不动。 /// 判据没变:**`body: Some(..)` 仍然只有外观组**。 #[test] fn the_settings_module_does_not_name_any_group_state() {