diff --git a/crates/app-iced/examples/pages_demo.rs b/crates/app-iced/examples/pages_demo.rs index 34122bf..f4fd806 100644 --- a/crates/app-iced/examples/pages_demo.rs +++ b/crates/app-iced/examples/pages_demo.rs @@ -21,7 +21,9 @@ fn main() -> ExitCode { eprintln!( "用法:pages_demo [--screen=instances|download|settings|logs] [--width ] \ [--height ] [--theme=light|dark] [--screenshot-theme=light|dark] \ - [--screenshot ] [--renderer=auto|gpu|cpu] [--software-renderer]" + [--screenshot ] [--renderer=auto|gpu|cpu] [--software-renderer] \ + [--scale-factor ] [--appearance=real|demo] [--passthrough=on|off] \ + [--force-state=rest|hover|pressed]" ); return ExitCode::from(2); } diff --git a/crates/app-iced/src/pages/appearance/section/accent.rs b/crates/app-iced/src/pages/appearance/section/accent.rs index da81fff..5768784 100644 --- a/crates/app-iced/src/pages/appearance/section/accent.rs +++ b/crates/app-iced/src/pages/appearance/section/accent.rs @@ -31,6 +31,7 @@ use unlucky_i18n::Lang; use unlucky_presenter::prefs::{AccentError, ACCENT_PRESETS}; use crate::app::components::inputs::{text_field, Field}; +use crate::app::components::interaction::Interaction; use crate::pages::Message; use crate::theme::{to_iced, Ui, STROKE_PX}; @@ -44,6 +45,7 @@ pub(super) fn push<'a>( ui: &'a Ui, state: &'a State, lang: Lang, + forced: Option, rows: &mut Vec>, ) { let accent = &state.accent; @@ -68,7 +70,7 @@ pub(super) fn push<'a>( .into(), ); - rows.push(presets(ui, accent, lang)); + rows.push(presets(ui, accent, lang, forced)); // 非法值:**结构化的原因**,不是「格式不对」这种没法行动的一句话。 if let Some(error) = accent.error() { @@ -97,7 +99,12 @@ fn accent_commit() -> Message { /// /// 为什么分两行:一行放 9 个控件在设置卡片的正文宽度里会被挤成「7 + 1」折行,最后一颗色块 /// 孤零零掉到下一行(出图实测)。分两行是**有意**的排版,不是被挤出来的。 -fn presets<'a>(ui: &'a Ui, accent: &'a Accent, lang: Lang) -> Element<'a, Message> { +fn presets<'a>( + ui: &'a Ui, + accent: &'a Accent, + lang: Lang, + forced: Option, +) -> Element<'a, Message> { let current = accent.persist_value(); let head = row![ container(label(ui, t(Key::SettingsAppearanceAccentPresets, lang))) @@ -107,6 +114,7 @@ fn presets<'a>(ui: &'a Ui, accent: &'a Accent, lang: Lang) -> Element<'a, Messag t(Key::SettingsAppearanceAccentFactory, lang), current.is_empty(), Change::Accent(String::new()), + forced, ), ] .spacing(ui.space(Space::X2)) 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 6c71e6b..0f31b34 100644 --- a/crates/app-iced/src/pages/appearance/section/bg_image.rs +++ b/crates/app-iced/src/pages/appearance/section/bg_image.rs @@ -13,6 +13,7 @@ use unlucky_i18n::Lang; use unlucky_presenter::prefs::BgFill; use crate::app::components::inputs::{slider, text_field, Field, Slider}; +use crate::app::components::interaction::Interaction; use crate::pages::Message; use crate::theme::Ui; @@ -27,6 +28,7 @@ pub(super) fn push<'a>( ui: &'a Ui, state: &'a State, lang: Lang, + forced: Option, rows: &mut Vec>, ) { // ④ 背景图开关 @@ -39,12 +41,14 @@ pub(super) fn push<'a>( t(Key::SettingsAppearanceOff, lang), !state.bg_enabled, Change::BgEnabled(false), + forced, ), choice( ui, t(Key::SettingsAppearanceOn, lang), state.bg_enabled, Change::BgEnabled(true), + forced, ), ], )); @@ -109,18 +113,21 @@ pub(super) fn push<'a>( t(Key::SettingsAppearanceBgFillCover, lang), state.bg_fill == BgFill::Cover, Change::BgFill(BgFill::Cover), + forced, ), choice( ui, t(Key::SettingsAppearanceBgFillContain, lang), state.bg_fill == BgFill::Contain, Change::BgFill(BgFill::Contain), + forced, ), choice( ui, t(Key::SettingsAppearanceBgFillTile, lang), state.bg_fill == BgFill::Tile, Change::BgFill(BgFill::Tile), + forced, ), ], )); 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 8b9c85f..9ae4767 100644 --- a/crates/app-iced/src/pages/appearance/section/colour_source.rs +++ b/crates/app-iced/src/pages/appearance/section/colour_source.rs @@ -5,6 +5,7 @@ use unlucky_i18n::catalog::Key; use unlucky_i18n::Lang; use unlucky_presenter::prefs::PaletteSource; +use crate::app::components::interaction::Interaction; use crate::pages::Message; use crate::theme::Ui; @@ -17,6 +18,7 @@ pub(super) fn push<'a>( ui: &'a Ui, state: &'a State, lang: Lang, + forced: Option, rows: &mut Vec>, ) { // ⑨ 配色来源 @@ -29,18 +31,21 @@ pub(super) fn push<'a>( t(Key::SettingsAppearancePaletteBackground, lang), state.palette_source == PaletteSource::BackgroundImage, Change::PaletteSource(PaletteSource::BackgroundImage), + forced, ), choice( ui, t(Key::SettingsAppearancePaletteWallpaper, lang), state.palette_source == PaletteSource::Wallpaper, Change::PaletteSource(PaletteSource::Wallpaper), + forced, ), choice( ui, t(Key::SettingsAppearancePalettePreset, lang), state.palette_source == PaletteSource::Preset, Change::PaletteSource(PaletteSource::Preset), + forced, ), ], )); diff --git a/crates/app-iced/src/pages/appearance/section/mod.rs b/crates/app-iced/src/pages/appearance/section/mod.rs index 2501406..72c96cf 100644 --- a/crates/app-iced/src/pages/appearance/section/mod.rs +++ b/crates/app-iced/src/pages/appearance/section/mod.rs @@ -85,15 +85,24 @@ mod window; /// /// `lang` 是**这一次生效的语言**:`settings::view` 的 `Language.effective`(M179)。壳把各段按 /// **视觉顺序**拼起来;每一段的语句逐字来自拆分前的 `section()`。 -pub fn section<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> { +/// +/// `forced` 是 `--force-state` 的落点(M285,`pages::Model::forced` 一路递到 `Group.body`):`None` = +/// 按控件自己的观测态画(产品路径的常态);`Some(..)` 只服务取证出图。它只改**画出来的**态—— +/// 每一段把它交给 [`choice`],由 [`super::super::settings::resolve_state`] 那个唯一 resolve 点落地。 +pub fn section<'a>( + ui: &'a Ui, + state: &'a State, + lang: Lang, + forced: Option, +) -> Element<'a, Message> { let mut rows: Vec> = Vec::new(); // M250:强调色排在最前 —— 它是「配色可自定义」这一页最直接的入口(键一直在,控件是新的)。 - accent::push(ui, state, lang, &mut rows); - window::push(ui, state, lang, &mut rows); + accent::push(ui, state, lang, forced, &mut rows); + window::push(ui, state, lang, forced, &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); + bg_image::push(ui, state, lang, forced, &mut rows); + colour_source::push(ui, state, lang, forced, &mut rows); card( ui, @@ -237,11 +246,14 @@ pub(super) fn choice_row<'a>( } /// 一个互斥按钮(选中态用 `SecondaryContainer`,未选中用透明 + `OnSurfaceVariant`)。 +/// +/// `forced`(M285)只往下递给 [`choice_style`]:`Some` 时按钮按那一态画,`None` 时按观测态画。 pub(super) fn choice<'a>( ui: &'a Ui, text: &'a str, selected: bool, change: Change, + forced: Option, ) -> Element<'a, Message> { let height = ui.metrics().control_height; let radius = ui.radius(ShapeScale::Full, height); @@ -263,7 +275,7 @@ pub(super) fn choice<'a>( ) .height(height) .padding(Padding::from([0.0, ui.space(Space::X3)])) - .style(choice_style(ui, selected, content_color, radius)) + .style(choice_style(ui, selected, content_color, radius, forced)) .on_press(Message::Appearance(change)) .into() } @@ -282,6 +294,7 @@ fn choice_style<'a>( selected: bool, content_color: Color, radius: Radius, + forced: Option, ) -> impl Fn(&IcedTheme, button::Status) -> button::Style + 'a { move |_theme, status| { let background = if selected { @@ -289,7 +302,7 @@ fn choice_style<'a>( } else { interaction::layer_color( ui, - Interaction::from_status(status), + crate::pages::settings::resolve_state(forced, status), Role::OnSurface, frame::BASE.role(), ) @@ -353,6 +366,7 @@ mod tests { false, ui.color(Role::OnSurfaceVariant), ui.radius(ShapeScale::Full, ui.metrics().control_height), + None, ); let theme = ui.theme(); let hover = solid(style(&theme, button::Status::Hovered)); diff --git a/crates/app-iced/src/pages/appearance/section/window.rs b/crates/app-iced/src/pages/appearance/section/window.rs index ff2ce98..6f2ca0b 100644 --- a/crates/app-iced/src/pages/appearance/section/window.rs +++ b/crates/app-iced/src/pages/appearance/section/window.rs @@ -14,6 +14,7 @@ use unlucky_i18n::Lang; use unlucky_presenter::prefs::WindowCorner; use crate::app::components::inputs::{slider, Slider}; +use crate::app::components::interaction::Interaction; use crate::pages::Message; use crate::theme::Ui; @@ -27,6 +28,7 @@ pub(super) fn push<'a>( ui: &'a Ui, state: &'a State, lang: Lang, + forced: Option, rows: &mut Vec>, ) { // ① 整窗透明度 @@ -59,12 +61,14 @@ pub(super) fn push<'a>( t(Key::SettingsAppearanceCornerSystem, lang), corner == WindowCorner::System, Change::Corner(WindowCorner::System), + forced, ), choice( ui, t(Key::SettingsAppearanceCornerSquare, lang), corner == WindowCorner::Square, Change::Corner(WindowCorner::Square), + forced, ), ], )); diff --git a/crates/app-iced/src/pages/appearance/tests.rs b/crates/app-iced/src/pages/appearance/tests.rs index 22e4603..34606d5 100644 --- a/crates/app-iced/src/pages/appearance/tests.rs +++ b/crates/app-iced/src/pages/appearance/tests.rs @@ -129,7 +129,7 @@ fn appearance_texts(lang: Lang, case: Case) -> Vec<(Key, String)> { probe::take(); probe::take_rendered(); - let _tree = super::section(&ui, &state, lang); + let _tree = super::section(&ui, &state, lang, None); let mut texts: Vec<(Key, String)> = probe::take() .into_iter() diff --git a/crates/app-iced/src/pages/harness.rs b/crates/app-iced/src/pages/harness.rs index d0d1a32..817ab19 100644 --- a/crates/app-iced/src/pages/harness.rs +++ b/crates/app-iced/src/pages/harness.rs @@ -3,7 +3,8 @@ //! **刻意不接 `cli.rs`**:等 `cli.rs` 具备 `--screen` / `--window-width` 之后,这个 harness 应当 //! 删掉,改由正式启动参数出图。参数:`--screen=instances|download|settings|logs`、`--width`、 //! `--height`、`--theme` / `--screenshot-theme`(后者优先)、`--screenshot`、`--renderer`、 -//! `--software-renderer`、`--scale-factor`、`--appearance=real|demo`、`--passthrough=on|off`。 +//! `--software-renderer`、`--scale-factor`、`--appearance=real|demo`、`--passthrough=on|off`、 +//! `--force-state=rest|hover|pressed`(M285:把控件钉到那一态出图,`rest` / 不给时截图通道默认就是它)。 //! 解析与默认值在 [`request`](M100 拆出去:本文件当时正好 400 行、零余量);没给 `--screenshot` //! 就只是开窗看。 //! @@ -40,9 +41,43 @@ //! 改成 900×600,落盘的就是 900×600 那张)。现在闸门在 [`should_request_capture`],日志里的 //! `落盘次数=` 必须恒为 1。 //! -//! **为什么不是给每个控件灌 forced**:设置页「外观」区的滑块 / 文本框 / 裸 `iced::button` 都不 -//! 消费 forced,那样做要改十几个组件文件——为取证去动产品组件,代价与风险都不对,而且新产品 -//! 组件随时会漏。 +//! **forced 能到哪儿(M285 订正)**:`--force-state` 只改**画出来的**态,不改状态机。它经 +//! `pages::Model.forced` → `settings::view(.., forced)` → `Group.body` 递到各控件。 +//! +//! **已经消费的**:外观区 11 处 `choice`、语言 3 枚 / 主题 3 枚 chip、日志 2 颗 `action_button` +//! ——这几类(`choice` / chip / `action_button`)一律走 `settings::resolve_state` 这个唯一 resolve 点; +//! 另有 `components::buttons::Button` 的 7 处 `.state(..)` 走 `settings::forced_button` +//! (update 3 / ai 2 / java 2)。 +//! +//! **仍然不消费的(残留快照——**别**把它们当成「已接」)**: +//! +//! 1. **Downloads 组整组**:`settings/download.rs` 的 `body` 形参就是 `_forced`(接住不用),组里的 +//! `switch` / `chip` / 两颗 `Button::new` 都不看 forced。实测 `--screen=download` 加 +//! `--force-state=rest|hover|pressed` **三张图 md5 全等**(`e283c585fc06a0dd08efaf5596c9aa6f`); +//! 2. 外观区的**滑块 / 文本框 / 8 颗预设色块**(`ACCENT_PRESETS: [&str; 8]`;`accent::swatch` 是 +//! 本地助手,没有 forced 形参,描边闭包只看 `button::Status`); +//! 3. `components::selection` 的 `chip` / `switch` 站点里**还没搭线的那几处**(已知:update 的自动 +//! 检查 filter chip、ai 的 provider chip / reveal chip / 开关、download 的 switch 与 chip、java 的 +//! `prefs_view` 两对上下移 chip 与 `download_view` 的三处 chip、以及 **`java/view.rs` 运行时的 +//! 「设为默认」chip**——`action_chip` 在非默认行返回可点的 `Chip::filter`,`runtime_row` 没有 +//! forced 形参。这一串会漂,接线时请更新)。**同源 chip 里 `general/language.rs` 与 +//! `general/theme.rs` 那 6 枚是已接的**(见上一段),别算进这里; +//! 4. java 的字段 / 预设按钮(`java/fields.rs`、`java/prefs_view.rs`)——`java/download_view.rs` 里 +//! 一个 `Button` 都没有,它的下载控件是 chip,已在第 3 条; +//! 5. **其它组的文本框**:`ai/view.rs` 的 base_url / model / api_key 三只 `Field`(java 的两样在第 4 +//! 条、download 的整组在第 1 条)。`Field` 本来就有 `.state(..)`,这里是没搭线。 +//! +//! 这份清单是**残留快照,不是契约**:接上任何一处就把它从清单里划掉,别让它变成「文档说没接所以 +//! 不用接」;也别把它当穷举——判据只有一条:**这个控件有没有拿到 `forced`**。 +//! +//! ⚠️ **别把「没搭线」说成「组件库缺能力」**:`Slider::state` / `Field::state` / `Chip::state` / +//! `Switch::state` 全都存在且**在被消费**(它们本来就是 gallery 状态矩阵用的「强制视觉态」)。 +//! 上面那些站点只是没把 `forced` 递进去;真正缺 forced 入口的只有本地助手 `accent::swatch`。 +//! +//! 本段之前写的是「裸 `iced::button` 都不消费 forced」——**那句正是 M282/M284 放弃像素验收的理由**, +//! 已经不成立(残余清单之外的那几类现在出得来像素证据,见 M285 的 `DevDocs/artifacts/forcedwire/`)。 +//! 但也**别**反过来把「裸 `button(...)`」当成「已消费」的同义词:8 颗预设色块本身就是裸 +//! `button(...)` 且不消费。 //! //! ```text //! cargo run -p unlucky-app-iced --example pages_demo -- --screen=instances --width=1180 \ diff --git a/crates/app-iced/src/pages/harness/app.rs b/crates/app-iced/src/pages/harness/app.rs index de198f9..3af1417 100644 --- a/crates/app-iced/src/pages/harness/app.rs +++ b/crates/app-iced/src/pages/harness/app.rs @@ -146,12 +146,15 @@ pub fn run(request: Request) -> Result<(), String> { iced::application( move || { let mut model = demo_model(boot.screen, boot.width, boot.height); - // 截图通道把**消费 forced 的那批控件**(实例页)钉成常态,与产品 `--screenshot` - // 的 `ForcedState::Rest` 同一条纪律。它管不到设置页 / 外观区(那边不读 forced), - // 那两屏的密闭靠窗口级 passthrough——别把这条当成万能的。 - if boot.screenshot.is_some() { - model.forced = Some(Interaction::Enabled); - } + // 截图通道把消费 forced 的控件钉成常态,与产品 `--screenshot` 的 `ForcedState::Rest` + // 同一条纪律;显式的 `--force-state=hover|pressed` **优先**(那是故意要画那个态)。 + // + // M285:这条线现在真的到得了设置页 / 外观区——`pages::Model.forced` 经 + // `settings::view(.., forced)` → `Group.body` → 语言档 / 主题档 / 日志按钮 / 外观 choice, + // 由 `settings::resolve_state` 落到样式闭包。此前那四处的状态层只有单测证据。 + model.forced = boot + .force_state + .or_else(|| boot.screenshot.is_some().then_some(Interaction::Enabled)); // M250:gallery 出图也要能看见强调色。**规则不在这里抄** —— 调产品用的同一个 // `theme::tokens`(优先级只定义一次)。如实补一句:harness 不建 `App`,所以这里 // 没有 D38 取色方案(第三个实参恒 `None`),出图**不覆盖**那条分支。 diff --git a/crates/app-iced/src/pages/harness/request.rs b/crates/app-iced/src/pages/harness/request.rs index 569271e..d7cd4a5 100644 --- a/crates/app-iced/src/pages/harness/request.rs +++ b/crates/app-iced/src/pages/harness/request.rs @@ -12,6 +12,7 @@ use std::path::PathBuf; use unlucky_design::color::SchemeFlavor; +use crate::app::components::interaction::Interaction; use crate::cli::Backend; use crate::pages::{layout::WidthClass, Screen}; @@ -47,6 +48,18 @@ pub struct Request { /// /// 只对 `--screen=settings` 有意义:别的页没有外观分组,静默接受会造出「以为接了线」的假图(U1)。 pub real_appearance: bool, + /// 强制交互态(`--force-state=rest|hover|pressed`):`Some` 时**控件按那一态画**(M285)。 + /// + /// 为什么这个开关在本文件而不在产品 bin:产品 bin 的 `--screen` 只认 `shell|gallery`,设置页 / + /// 外观区只能走本 harness 出图(`examples/pages_demo.rs`)。M282 / M284 各自撞过一次—— + /// `pages::Model.forced` 到不了设置页,于是「hover 与 pressed 必须不同」这条判据**做不出像素证据**, + /// 只能降级成单测。接上之后两条态各自一张图。 + /// + /// 词与语义与产品的 `cli::ForcedState` 一致(`rest|hover|pressed`),但**刻意不共用那个 enum**: + /// 产品那条通道的默认值与守卫(`--screenshot` 恒钉 `Rest`)是另一套,把两者拧在一起会让 + /// 「取证通道的默认值」和「产品窗口的默认值」互相污染。这里存的是下游形状 [`Interaction`], + /// 落点是 `pages::Model.forced`(`harness/app.rs`)。 + pub force_state: Option, /// 截图窗口开**指针穿透**(`window::enable_mouse_passthrough` ⇒ winit `set_cursor_hittest(false)`)。 /// /// 默认:给了 `--screenshot` 就开;**交互模式(没有截图)一律关**——交互窗口收不到鼠标是产品事故, @@ -68,6 +81,7 @@ impl Default for Request { backend: Backend::Auto, scale_factor: None, real_appearance: false, + force_state: None, passthrough: false, } } @@ -138,6 +152,20 @@ impl Request { } }; } + "--force-state" => { + // 三档词与产品 bin 同一套(`cli.rs` 的 `--force-state`),报错文案也照抄。 + request.force_state = + Some(match take(&mut args, inline, "--force-state")?.as_str() { + "rest" => Interaction::Enabled, + "hover" => Interaction::Hovered, + "pressed" => Interaction::Pressed, + other => { + return Err(format!( + "--force-state 只认 rest|hover|pressed,收到 {other:?}" + )) + } + }); + } "--passthrough" => { passthrough = Some(match take(&mut args, inline, "--passthrough")?.as_str() { "on" => true, @@ -183,7 +211,7 @@ impl Request { Ok(request) } - /// 出图文件名片段(`[-real]---w<宽>`,归档用)。 + /// 出图文件名片段(`[-real][-hover|-pressed]---w<宽>`,归档用)。 pub fn slug(&self) -> String { let class = WidthClass::of(self.width); let flavor = if self.flavor.is_dark() { @@ -198,6 +226,13 @@ impl Request { } else { self.screen.slug().to_owned() }; + // 强制态也进文件名(`-hover` / `-pressed`):同一屏在两个 forced 下是**两张不同的证据图**, + // 归档里必须能分开(与 `-real` 同一条理由)。`rest` / 不给 = 不加后缀 ⇒ 既有文件名逐字不变。 + let screen = match self.force_state { + Some(Interaction::Hovered) => format!("{screen}-hover"), + Some(Interaction::Pressed) => format!("{screen}-pressed"), + _ => screen, + }; format!("{screen}-{}-{flavor}-w{width}", class.name()) } } diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index dbd6b66..76163e9 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -282,7 +282,9 @@ fn body<'a>( content::view(ui, metrics, &model.content, model.lang, model.window_height) } // M159:设置页「更新」组要画 presenter 的更新快照(不在 `States` 里,理由见该组文档)。 - Screen::Settings => settings::view(ui, metrics, states, language, &model.update), + Screen::Settings => { + settings::view(ui, metrics, states, language, &model.update, model.forced) + } Screen::Logs => logs::view(ui, metrics, &model.logs, model.lang), }; diff --git a/crates/app-iced/src/pages/settings/ai/view.rs b/crates/app-iced/src/pages/settings/ai/view.rs index 542ea6f..96ebaab 100644 --- a/crates/app-iced/src/pages/settings/ai/view.rs +++ b/crates/app-iced/src/pages/settings/ai/view.rs @@ -24,11 +24,12 @@ use unlucky_i18n::{Catalog, Key, Lang}; use crate::app::components::buttons::{self, Button}; use crate::app::components::inputs::{text_field, Field, FieldVariant}; +use crate::app::components::interaction::Interaction; use crate::app::components::selection::{chip, switch, Chip, Switch}; use crate::pages::frame::{card, divider, text_role_owned}; use crate::pages::settings::ai::outcome::ProbeKind; use crate::pages::settings::ai::state::{Probe, ProviderId, SaveNotice, Secret, State, Status}; -use crate::pages::settings::{ai_about, GroupMessage, States}; +use crate::pages::settings::{ai_about, forced_button, GroupMessage, States}; use crate::pages::Message; use crate::theme::Ui; @@ -38,11 +39,21 @@ fn msg(message: ai_about::Message) -> Message { } /// 一张卡片(`pages::settings::form` 按下标把 `Lang` 递进来,见 `settings/mod.rs`)。 -pub(crate) fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Element<'a, Message> { - body_of(ui, &states.groups.ai_about, lang) +pub(crate) fn body<'a>( + ui: &'a Ui, + states: States<'a>, + lang: Lang, + forced: Option, +) -> Element<'a, Message> { + body_of(ui, &states.groups.ai_about, lang, forced) } -fn body_of<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> { +fn body_of<'a>( + ui: &'a Ui, + state: &'a State, + lang: Lang, + forced: Option, +) -> Element<'a, Message> { let catalog = lang.catalog(); let mut stacked = column![].spacing(ui.space(Space::X2)).width(Length::Fill); @@ -56,7 +67,7 @@ fn body_of<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> stacked = stacked.push(model_field(ui, catalog, state)); stacked = stacked.push(key_field(ui, catalog, state)); stacked = stacked.push(divider(ui)); - stacked = stacked.push(actions_row(ui, catalog, state)); + stacked = stacked.push(actions_row(ui, catalog, state, forced)); stacked = stacked.push(hint_line(ui, catalog, state)); if let Some(notice) = &state.notice { @@ -267,10 +278,21 @@ fn key_field<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, .into() } -fn actions_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> { +fn actions_row<'a>( + ui: &'a Ui, + catalog: Catalog, + state: &State, + forced: Option, +) -> Element<'a, Message> { let save_label = catalog.text(Key::SettingsAiSave); let save = if state.dirty && state.storage { - buttons::filled(ui, Button::new(save_label, msg(ai_about::Message::Save))) + buttons::filled( + ui, + forced_button( + Button::new(save_label, msg(ai_about::Message::Save)), + forced, + ), + ) } else { buttons::filled(ui, Button::disabled(save_label)) }; @@ -281,7 +303,13 @@ fn actions_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, M catalog.text(Key::SettingsAiTest) }; let test = if state.can_test() { - buttons::tonal(ui, Button::new(test_label, msg(ai_about::Message::Test))) + buttons::tonal( + ui, + forced_button( + Button::new(test_label, msg(ai_about::Message::Test)), + forced, + ), + ) } else { buttons::tonal(ui, Button::disabled(test_label)) }; diff --git a/crates/app-iced/src/pages/settings/appearance.rs b/crates/app-iced/src/pages/settings/appearance.rs index ba2214c..f17f036 100644 --- a/crates/app-iced/src/pages/settings/appearance.rs +++ b/crates/app-iced/src/pages/settings/appearance.rs @@ -19,6 +19,7 @@ use iced::Element; use unlucky_design::icons::IconKind; use unlucky_i18n::Lang; +use crate::app::components::interaction::Interaction; use crate::theme::Ui; use super::{Group, Message, States}; @@ -39,9 +40,15 @@ pub(super) const GROUP: Group = Group { /// 本组的真控件渲染器:有外观状态就画真控件(M93),没有(harness)返回 `None`。 /// -/// `lang` 一路递给 `pages::appearance::section`——它是这一组唯一知道「界面现在是什么语言」的东西。 -fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Option> { +/// `lang` 一路递给 `pages::appearance::section`——它是这一组唯一知道「界面现在是什么语言」的东西; +/// `forced`(`--force-state`,M285)同样一路递下去,由 `section` 分给各段控件。 +fn body<'a>( + ui: &'a Ui, + states: States<'a>, + lang: Lang, + forced: Option, +) -> Option> { states .appearance - .map(|state| crate::pages::appearance::section(ui, state, lang)) + .map(|state| crate::pages::appearance::section(ui, state, lang, forced)) } diff --git a/crates/app-iced/src/pages/settings/download.rs b/crates/app-iced/src/pages/settings/download.rs index 87454b6..526be22 100644 --- a/crates/app-iced/src/pages/settings/download.rs +++ b/crates/app-iced/src/pages/settings/download.rs @@ -59,6 +59,7 @@ use unlucky_presenter::dto::find_row; use crate::app::components::buttons::{self, Button}; use crate::app::components::inputs::{text_field, Field, FieldVariant}; +use crate::app::components::interaction::Interaction; use crate::app::components::selection::{chip, switch, Chip, Switch}; use crate::pages::frame::text_role_owned; use crate::pages::Message as PageMessage; @@ -69,6 +70,9 @@ use super::{Group, States}; /// Downloads 分组。 /// +/// `body` 的 `_forced`(`--force-state`,M285)**接住不用**:本组控件是 `components::selection` +/// 的 `chip` / `switch` 与 `Field`,components 层没有 forced 开口。留形参只为 `Group.body` 只有一个签名。 +/// /// `rows` 只在**没有快照**时画(harness / 首帧):它们是语义行(出厂值 / 区间 / 三态), /// 不是当前值——M193 用户的投诉正是「把演示值当成了真值」。 pub(super) const GROUP: Group = Group { @@ -179,7 +183,12 @@ fn msg(message: Message) -> PageMessage { /// /// 为什么不给 [`block`] 加 padding:那会把卡片的**第一行顶距**也撑开(卡内边距 20 + 12),与其它四组 /// 的第一行不再对齐;`spacing` 只作用在子节点之间,首行顶距与末行底距仍是卡片的 20。 -fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Option> { +fn body<'a>( + ui: &'a Ui, + states: States<'a>, + lang: Lang, + _forced: Option, +) -> Option> { let group = state_group(&states)?; let drafts = &states.groups.download; let catalog = lang.catalog(); diff --git a/crates/app-iced/src/pages/settings/frame.rs b/crates/app-iced/src/pages/settings/frame.rs index 106b78b..7d19e8d 100644 --- a/crates/app-iced/src/pages/settings/frame.rs +++ b/crates/app-iced/src/pages/settings/frame.rs @@ -372,8 +372,9 @@ mod no_stray_empty_state { fn the_form_holds_exactly_one_column_per_group() { let ui = ui(); let update = Update::default(); + let language = Language::default(); - let narrow = section_columns(&ui, None, states(), Language::default(), &update); + let narrow = section_columns(&ui, None, states(), language, &update, None); assert_eq!( narrow.len(), GROUPS.len(), @@ -381,7 +382,7 @@ mod no_stray_empty_state { ); for index in 0..GROUPS.len() { - let single = section_columns(&ui, Some(index), states(), Language::default(), &update); + let single = section_columns(&ui, Some(index), states(), language, &update, None); assert_eq!( single.len(), 1, diff --git a/crates/app-iced/src/pages/settings/general.rs b/crates/app-iced/src/pages/settings/general.rs index de136a8..0b6d03a 100644 --- a/crates/app-iced/src/pages/settings/general.rs +++ b/crates/app-iced/src/pages/settings/general.rs @@ -38,20 +38,16 @@ //! //! 分组元数据只有一份(这里的 [`GROUP`]):`settings/mod.rs` 的 `GROUPS` 直接引用它。 -use iced::border::Radius; -use iced::widget::{button, column, container, row}; -use iced::{ - Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme as IcedTheme, -}; +use iced::widget::{column, container, row}; +use iced::{Alignment, Element, Length, Padding}; use unlucky_design::color::Role; use unlucky_design::icons::IconKind; use unlucky_design::spacing::Space; use unlucky_design::typography::TypeRole; use unlucky_i18n::catalog::Key; -use unlucky_i18n::LangChoice; -use crate::app::components::interaction::{self, Interaction}; +use crate::app::components::interaction::Interaction; use crate::fonts::install::{CjkInstall, FetchClass}; use crate::pages::{frame, Language, Message}; use crate::theme::Ui; @@ -59,6 +55,7 @@ use crate::theme::Ui; use super::frame::{card, divider}; use super::{Group, States}; +mod language; mod theme; /// General 分组(左列名固定英文:左列是**导航**,与导航轨同源,不进 catalog)。 @@ -76,10 +73,17 @@ pub(super) const GROUP: Group = Group { }; /// 这一组的正文(**产品路径**)。 -pub(super) fn body<'a>(ui: &'a Ui, states: States<'a>, language: Language) -> Element<'a, Message> { +/// +/// `forced` 是 `--force-state`(M285):本组只有语言行 / 主题行 / 日志行三处有可交互控件,三处都往下递。 +pub(super) fn body<'a>( + ui: &'a Ui, + states: States<'a>, + language: Language, + forced: Option, +) -> Element<'a, Message> { let catalog = language.effective.catalog(); let mut stacked = column![].spacing(0.0).width(Length::Fill); - stacked = stacked.push(language_row(ui, language)); + stacked = stacked.push(language::view(ui, language, forced)); // M168(D28 后半):中文字体的下载状态行。紧贴语言行——用户在这里手选中文,反馈就该在这行下面, // 而不是等他切到别的页面才发现「刚才那几秒在干嘛」。 stacked = stacked.push(divider(ui)); @@ -92,12 +96,13 @@ pub(super) fn body<'a>(ui: &'a Ui, states: States<'a>, language: Language) -> El ui, &catalog, states.groups.language_appearance.theme, + forced, )); // M188:日志文件夹(路径 + 打开 + 复制路径)。放在「启动页/关闭按钮」之前——用户投诉 // 「找不到崩溃日志」要的答案,不该埋在只读行后面。 stacked = stacked.push(divider(ui)); - stacked = stacked.push(super::logs_row::folder_row(ui, &catalog)); + stacked = stacked.push(super::logs_row::folder_row(ui, &catalog, forced)); // 启动页 / 关闭按钮:还没有后端,只读;文案已经进 catalog。 for (label, value) in [ @@ -215,66 +220,6 @@ fn font_row<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> { stacked.into() } -/// 语言行:标题 + 生效语言回显 + 三档互斥按钮 + 一句说明。 -fn language_row<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> { - let catalog = language.effective.catalog(); - let effective = LangChoice::Fixed(language.effective).label_key(); - - let head = row![ - container(frame::text_role( - ui, - TypeRole::BodyMedium, - false, - catalog.text(Key::SettingsLanguage), - ui.color(Role::OnSurface), - )) - .width(Length::Fill) - .align_y(Alignment::Center), - frame::text_role_owned( - ui, - TypeRole::LabelLarge, - true, - catalog.render( - Key::SettingsLanguageEffective, - &[("language", catalog.text(effective))], - ), - ui.color(Role::OnPrimaryFixedVariant), - ), - ] - .width(Length::Fill) - .align_y(Alignment::Center); - - let chips = row(LangChoice::ALL - .iter() - .map(|choice| { - chip( - ui, - catalog.text(choice.label_key()), - *choice == language.choice, - *choice, - ) - }) - .collect::>()) - .spacing(ui.space(Space::X1)) - .align_y(Alignment::Center); - - column![ - head, - chips, - frame::text_role( - ui, - TypeRole::BodySmall, - false, - catalog.text(Key::SettingsLanguageHint), - ui.color(Role::OnSurfaceVariant), - ), - ] - .spacing(ui.space(Space::X1)) - .width(Length::Fill) - .padding(Padding::from([ui.space(Space::X2), 0.0])) - .into() -} - /// 一个只读的「标签 + 取值」行(与 `frame::demo_card` 的一行同高同排版)。 fn setting_row<'a>(ui: &'a Ui, label: &'a str, value: &'a str) -> Element<'a, Message> { let height = ui.metrics().control_height + Space::X2.dp(); @@ -302,89 +247,3 @@ fn setting_row<'a>(ui: &'a Ui, label: &'a str, value: &'a str) -> Element<'a, Me .align_y(Alignment::Center) .into() } - -/// 一个互斥按钮(选中态 `SecondaryContainer`,未选中透明 + `OnSurfaceVariant`)。 -/// -/// 与 `pages/appearance/section/mod.rs` 的 `choice` 同款几何,但文案来自 catalog(那份是英文基线)。 -/// -/// **几何(M229)**:取规格卡的 chip(高 32、圆角 8、内距 16、未选中 1px `OutlineVariant`), -/// 与同屏的主题三档 chips 完全同源——两行都是「并排的互斥筛选」,长得不一样会让读者以为是 -/// 两种控件(M229 第一版就是这样:语言 40 高药丸、主题 32 高方角,出图一眼就不齐)。 -fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, choice: LangChoice) -> Element<'a, Message> { - let box_ = unlucky_design::inkcat::metrics::CHIP; - let height = box_.height; - let content_color = if selected { - ui.color(Role::OnSecondaryContainer) - } else { - ui.color(Role::OnSurfaceVariant) - }; - let border_color = ui.color(Role::OutlineVariant); - let stroke = crate::theme::STROKE_PX; - let radius = ui.radius(box_.shape, height); - - button( - container(frame::text_role( - ui, - TypeRole::LabelLarge, - selected, - text, - content_color, - )) - .align_y(Alignment::Center), - ) - .height(height) - .padding(Padding::from([ - 0.0, - unlucky_design::inkcat::metrics::CHIP_PAD_X, - ])) - .style(chip_style( - ui, - selected, - content_color, - border_color, - stroke, - radius, - )) - .on_press(Message::Language(choice)) - .into() -} - -/// [`chip`] 的样式闭包(抽成自由函数:单测要**直接**喂 `Hovered` / `Pressed` 两态)。 -/// -/// - **α 只从 [`Interaction`] 来**:hover 8% / pressed 10%(M3)。M284 之前两种态合并成一条 -/// `matches!(status, Hovered | Pressed)` 分支、都叠 `StateLayer::Hover`,按下于是被压成 8%; -/// - **合成底是 [`frame::BASE`]**(M237:这一组住在 `frame::card` 里,状态层叠卡底而不是画布)。 -fn chip_style<'a>( - ui: &'a Ui, - selected: bool, - content_color: Color, - border_color: Color, - stroke: f32, - radius: Radius, -) -> impl Fn(&IcedTheme, button::Status) -> button::Style + 'a { - move |_theme, status| { - let background = if selected { - Some(Background::Color(ui.color(Role::SecondaryContainer))) - } else { - interaction::layer_color( - ui, - Interaction::from_status(status), - Role::OnSurface, - frame::BASE.role(), - ) - .map(Background::Color) - }; - - button::Style { - background, - text_color: content_color, - border: Border { - color: border_color, - width: if selected { 0.0 } else { stroke }, - radius, - }, - shadow: Shadow::default(), - snap: true, - } - } -} diff --git a/crates/app-iced/src/pages/settings/general/language.rs b/crates/app-iced/src/pages/settings/general/language.rs new file mode 100644 index 0000000..5d1980e --- /dev/null +++ b/crates/app-iced/src/pages/settings/general/language.rs @@ -0,0 +1,186 @@ +//! 设置页 General 组的**语言行**(M285 `LANGSET`:从 `general.rs` 纯搬移)。 +//! +//! 搬出来的理由只有行数:`general.rs` 390 行、撞 400 行硬线(`components/gate.rs`), +//! 而 M285 要给这一行的三枚互斥按钮接 `forced: Option`(`--force-state` 直通)。 +//! 切法与 [`super::theme`] 完全同构——**一个行控件一个文件**:正文 [`view`] + 它的 [`chip`] +//! + [`chip_style`](样式闭包抽成自由函数,单测才能直接喂 `Hovered` / `Pressed` 两态)。 +//! +//! 搬移是逐字搬的:三个函数的语句、注释一个字没动。 + +use iced::border::Radius; +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::spacing::Space; +use unlucky_design::typography::TypeRole; +use unlucky_i18n::catalog::Key; +use unlucky_i18n::LangChoice; + +use crate::app::components::interaction::{self, Interaction}; +use crate::pages::{frame, Language, Message}; +use crate::theme::Ui; + +/// 语言行:标题 + 生效语言回显 + 三档互斥按钮 + 一句说明。 +pub(super) fn view<'a>( + ui: &'a Ui, + language: Language, + forced: Option, +) -> Element<'a, Message> { + let catalog = language.effective.catalog(); + let effective = LangChoice::Fixed(language.effective).label_key(); + + let head = row![ + container(frame::text_role( + ui, + TypeRole::BodyMedium, + false, + catalog.text(Key::SettingsLanguage), + ui.color(Role::OnSurface), + )) + .width(Length::Fill) + .align_y(Alignment::Center), + frame::text_role_owned( + ui, + TypeRole::LabelLarge, + true, + catalog.render( + Key::SettingsLanguageEffective, + &[("language", catalog.text(effective))], + ), + ui.color(Role::OnPrimaryFixedVariant), + ), + ] + .width(Length::Fill) + .align_y(Alignment::Center); + + let chips = row(LangChoice::ALL + .iter() + .map(|choice| { + chip( + ui, + catalog.text(choice.label_key()), + *choice == language.choice, + *choice, + forced, + ) + }) + .collect::>()) + .spacing(ui.space(Space::X1)) + .align_y(Alignment::Center); + + column![ + head, + chips, + frame::text_role( + ui, + TypeRole::BodySmall, + false, + catalog.text(Key::SettingsLanguageHint), + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill) + .padding(Padding::from([ui.space(Space::X2), 0.0])) + .into() +} + +/// 一个互斥按钮(选中态 `SecondaryContainer`,未选中透明 + `OnSurfaceVariant`)。 +/// +/// 与 `pages/appearance/section/mod.rs` 的 `choice` 同款几何,但文案来自 catalog(那份是英文基线)。 +/// +/// **几何(M229)**:取规格卡的 chip(高 32、圆角 8、内距 16、未选中 1px `OutlineVariant`), +/// 与同屏的主题三档 chips 完全同源——两行都是「并排的互斥筛选」,长得不一样会让读者以为是 +/// 两种控件(M229 第一版就是这样:语言 40 高药丸、主题 32 高方角,出图一眼就不齐)。 +fn chip<'a>( + ui: &'a Ui, + text: &'a str, + selected: bool, + choice: LangChoice, + forced: Option, +) -> Element<'a, Message> { + let box_ = unlucky_design::inkcat::metrics::CHIP; + let height = box_.height; + let content_color = if selected { + ui.color(Role::OnSecondaryContainer) + } else { + ui.color(Role::OnSurfaceVariant) + }; + let border_color = ui.color(Role::OutlineVariant); + let stroke = crate::theme::STROKE_PX; + let radius = ui.radius(box_.shape, height); + + button( + container(frame::text_role( + ui, + TypeRole::LabelLarge, + selected, + text, + content_color, + )) + .align_y(Alignment::Center), + ) + .height(height) + .padding(Padding::from([ + 0.0, + unlucky_design::inkcat::metrics::CHIP_PAD_X, + ])) + .style(chip_style( + ui, + selected, + content_color, + border_color, + stroke, + radius, + forced, + )) + .on_press(Message::Language(choice)) + .into() +} + +/// [`chip`] 的样式闭包(抽成自由函数:单测要**直接**喂 `Hovered` / `Pressed` 两态)。 +/// +/// `forced`(M285)是 `--force-state` 的落点,交给 `settings::resolve_state`(全仓唯一的 resolve 点); +/// `None` 时行为与 M285 之前逐字节相同。`pub(super)` 是因为 `general/theme.rs` 的单测要直接喂它。 +/// +/// - **α 只从 [`Interaction`] 来**:hover 8% / pressed 10%(M3)。M284 之前两种态合并成一条 +/// `matches!(status, Hovered | Pressed)` 分支、都叠 `StateLayer::Hover`,按下于是被压成 8%; +/// - **合成底是 [`frame::BASE`]**(M237:这一组住在 `frame::card` 里,状态层叠卡底而不是画布)。 +pub(super) fn chip_style<'a>( + ui: &'a Ui, + selected: bool, + content_color: Color, + border_color: Color, + stroke: f32, + radius: Radius, + forced: Option, +) -> impl Fn(&IcedTheme, button::Status) -> button::Style + 'a { + move |_theme, status| { + let background = if selected { + Some(Background::Color(ui.color(Role::SecondaryContainer))) + } else { + interaction::layer_color( + ui, + crate::pages::settings::resolve_state(forced, status), + Role::OnSurface, + frame::BASE.role(), + ) + .map(Background::Color) + }; + + button::Style { + background, + text_color: content_color, + border: Border { + color: border_color, + width: if selected { 0.0 } else { stroke }, + radius, + }, + shadow: Shadow::default(), + snap: true, + } + } +} diff --git a/crates/app-iced/src/pages/settings/general/theme.rs b/crates/app-iced/src/pages/settings/general/theme.rs index 18611f9..2ef5c07 100644 --- a/crates/app-iced/src/pages/settings/general/theme.rs +++ b/crates/app-iced/src/pages/settings/general/theme.rs @@ -31,7 +31,14 @@ use crate::theme::Ui; pub(super) const THEME_MODES: [ThemeMode; 3] = [ThemeMode::Dark, ThemeMode::Light, ThemeMode::Auto]; /// 主题行:标题 + 三档互斥 chip + 一句说明(排版与语言行同族)。 -pub(super) fn view<'a>(ui: &'a Ui, catalog: &Catalog, mode: ThemeMode) -> Element<'a, Message> { +/// +/// `forced`(M285)只往下递给 chip:`None` = 按观测态画(产品路径的常态)。 +pub(super) fn view<'a>( + ui: &'a Ui, + catalog: &Catalog, + mode: ThemeMode, + forced: Option, +) -> Element<'a, Message> { let head = row![ container(frame::text_role( ui, @@ -61,6 +68,7 @@ pub(super) fn view<'a>(ui: &'a Ui, catalog: &Catalog, mode: ThemeMode) -> Elemen catalog.text(mode_label(*choice)), *choice == mode, *choice, + forced, ) }) .collect::>()) @@ -97,7 +105,13 @@ fn mode_label(mode: ThemeMode) -> Key { /// /// 几何取规格卡的 chip(高 32、圆角 8,`inkcat::metrics::CHIP`):主题三档是**可并排的筛选**, /// 不是主行动按钮,用 chip 档比语言行那枚 40 高药丸更贴规格卡。 -fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, mode: ThemeMode) -> Element<'a, Message> { +fn chip<'a>( + ui: &'a Ui, + text: &'a str, + selected: bool, + mode: ThemeMode, + forced: Option, +) -> Element<'a, Message> { let box_ = chipbox::CHIP; let height = box_.height; let content_color = if selected { @@ -128,6 +142,7 @@ fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, mode: ThemeMode) -> Eleme border_color, stroke, radius, + forced, )) .on_press(Message::Theme(mode)) .into() @@ -145,6 +160,7 @@ fn chip_style<'a>( border_color: Color, stroke: f32, radius: Radius, + forced: Option, ) -> impl Fn(&IcedTheme, button::Status) -> button::Style + 'a { move |_theme, status| { let background = if selected { @@ -152,7 +168,7 @@ fn chip_style<'a>( } else { interaction::layer_color( ui, - Interaction::from_status(status), + crate::pages::settings::resolve_state(forced, status), Role::OnSurface, frame::BASE.role(), ) @@ -254,17 +270,19 @@ mod tests { ui.color(Role::OutlineVariant), crate::theme::STROKE_PX, ui.radius(chipbox::CHIP.shape, chipbox::CHIP.height), + None, ), ); } /// **语言行那枚 chip**(`general::chip` 的 `chip_style`)同理:hover 8% / pressed 10%。 /// - /// 为什么这条断言住在 `theme.rs` 而不是 `general.rs`:后者已经 389 行(硬线 400),而 - /// `general` 的私有项对子模块可见(Rust 的可见性包含后代模块),搬过来不用加 `pub`。 + /// 为什么这条断言住在 `theme.rs` 而不是语言行自己的文件:M285 起语言行在 + /// `general/language.rs`,它的 `chip_style` 声明成 `pub(super)`(`general` 子树可见), + /// 所以这里不必把它抬成 crate 面。 #[test] fn the_language_chip_draws_pressed_darker_than_hover() { - use super::super::chip_style as language_chip_style; + use super::super::language::chip_style as language_chip_style; let ui = ui(); assert_two_layers( @@ -276,6 +294,7 @@ mod tests { ui.color(Role::OutlineVariant), crate::theme::STROKE_PX, ui.radius(chipbox::CHIP.shape, chipbox::CHIP.height), + None, ), ); } diff --git a/crates/app-iced/src/pages/settings/java/view.rs b/crates/app-iced/src/pages/settings/java/view.rs index 25b445f..56e8ca2 100644 --- a/crates/app-iced/src/pages/settings/java/view.rs +++ b/crates/app-iced/src/pages/settings/java/view.rs @@ -24,6 +24,7 @@ use unlucky_design::typography::TypeRole; use unlucky_i18n::{Catalog, Key, Lang}; use crate::app::components::buttons::{self, Button}; +use crate::app::components::interaction::Interaction; use crate::app::components::selection::{chip, Chip}; use crate::pages::frame::{card, divider, text_role_owned, BASE}; use crate::pages::Message; @@ -32,7 +33,7 @@ use crate::theme::Ui; use super::fields::{actions_row, add_row, hint_line, jvm_field, memory_field, notice_line}; use super::state::{Progress, RuntimeRow, State}; use super::Message as JavaMessage; -use crate::pages::settings::{GroupMessage, States}; +use crate::pages::settings::{forced_button, GroupMessage, States}; /// 一条本组消息 → 页面消息。 pub(super) fn msg(message: JavaMessage) -> Message { @@ -40,15 +41,25 @@ pub(super) fn msg(message: JavaMessage) -> Message { } /// 一张卡片(`pages::settings::form` 按下标把 `Lang` 递进来)。 -pub(crate) fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Element<'a, Message> { - body_of(ui, &states.groups.java, lang) +pub(crate) fn body<'a>( + ui: &'a Ui, + states: States<'a>, + lang: Lang, + forced: Option, +) -> Element<'a, Message> { + body_of(ui, &states.groups.java, lang, forced) } -fn body_of<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> { +fn body_of<'a>( + ui: &'a Ui, + state: &'a State, + lang: Lang, + forced: Option, +) -> Element<'a, Message> { let catalog = lang.catalog(); let mut stacked = column![].spacing(ui.space(Space::X2)).width(Length::Fill); - stacked = stacked.push(status_row(ui, catalog, state)); + stacked = stacked.push(status_row(ui, catalog, state, forced)); stacked = stacked.push(divider(ui)); stacked = stacked.push(runtime_list(ui, catalog, state)); stacked = stacked.push(divider(ui)); @@ -77,7 +88,12 @@ fn body_of<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> /// /// 「取消」的语义(r1 复审 P2-5):按下之后到线程真退出之间,界面显示的是**正在取消…**, /// 「立即扫描」保持禁用——否则用户能在旧线程还活着的时候起第二个。 -fn status_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> { +fn status_row<'a>( + ui: &'a Ui, + catalog: Catalog, + state: &State, + forced: Option, +) -> Element<'a, Message> { let (text, scanning) = match (state.scanning, state.scanned) { (Some(_), _) if state.cancelled => { (catalog.text(Key::SettingsJavaCancelling).to_string(), true) @@ -121,16 +137,22 @@ fn status_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Me } else { buttons::filled( ui, - Button::new(scan_label, msg(JavaMessage::Scan)).base(BASE), + forced_button( + Button::new(scan_label, msg(JavaMessage::Scan)).base(BASE), + forced, + ), ) }; // 已经按过取消(线程还没退出)时,「取消」也灰掉:再按一次没有任何意义。 let cancel = buttons::tonal( ui, if scanning && !state.cancelled { - Button::new( - catalog.text(Key::SettingsJavaCancel), - msg(JavaMessage::CancelScan), + forced_button( + Button::new( + catalog.text(Key::SettingsJavaCancel), + msg(JavaMessage::CancelScan), + ), + forced, ) } else { Button::disabled(catalog.text(Key::SettingsJavaCancel)) diff --git a/crates/app-iced/src/pages/settings/logs_row.rs b/crates/app-iced/src/pages/settings/logs_row.rs index d006c90..9ccd413 100644 --- a/crates/app-iced/src/pages/settings/logs_row.rs +++ b/crates/app-iced/src/pages/settings/logs_row.rs @@ -29,7 +29,11 @@ use crate::pages::{frame, Action, Message}; use crate::theme::Ui; /// 这一行(竖排:标签 / 路径 / 两个按钮 / 一句说明)。 -pub(super) fn folder_row<'a>(ui: &'a Ui, catalog: &Catalog) -> Element<'a, Message> { +pub(super) fn folder_row<'a>( + ui: &'a Ui, + catalog: &Catalog, + forced: Option, +) -> Element<'a, Message> { let path = crate::app::logs_folder::logs_dir() .map(|dir| dir.display().to_string()) .unwrap_or_else(|| catalog.text(Key::SettingsLogsUnknown).to_string()); @@ -40,11 +44,13 @@ pub(super) fn folder_row<'a>(ui: &'a Ui, catalog: &Catalog) -> Element<'a, Messa ui, catalog.text(Key::LogsOpenFolder), Message::Action(Action::OpenLogs), + forced, ), action_button( ui, catalog.text(Key::SettingsLogsCopy), Message::CopySummary(path.clone()), + forced, ), ] .spacing(ui.space(Space::X2)) @@ -97,7 +103,12 @@ fn frame_text_owned<'a>( } /// 描边小按钮(与设置页其他「次级动作」同款:透明底、hover 出状态层、全圆角)。 -fn action_button<'a>(ui: &'a Ui, label: &'a str, message: Message) -> Element<'a, Message> { +fn action_button<'a>( + ui: &'a Ui, + label: &'a str, + message: Message, + forced: Option, +) -> Element<'a, Message> { let height = ui.metrics().control_height; let content = ui.color(Role::OnSurface); let outline = ui.color(Role::OutlineVariant); @@ -107,7 +118,7 @@ fn action_button<'a>(ui: &'a Ui, label: &'a str, message: Message) -> Element<'a ) .height(height) .padding(Padding::from([0.0, ui.space(Space::X3)])) - .style(action_style(ui, content, outline, radius)) + .style(action_style(ui, content, outline, radius, forced)) .on_press(message) .into() } @@ -122,11 +133,12 @@ fn action_style<'a>( content: Color, outline: Color, radius: Radius, + forced: Option, ) -> impl Fn(&IcedTheme, button::Status) -> button::Style + 'a { move |_theme, status| { let background = interaction::layer_color( ui, - Interaction::from_status(status), + crate::pages::settings::resolve_state(forced, status), Role::OnSurface, frame::BASE.role(), ) @@ -189,6 +201,7 @@ mod tests { ui.color(Role::OnSurface), ui.color(Role::OutlineVariant), ui.radius(ShapeScale::Full, ui.metrics().control_height), + None, ); let theme = ui.theme(); let hover = solid(style(&theme, button::Status::Hovered)); diff --git a/crates/app-iced/src/pages/settings/mod.rs b/crates/app-iced/src/pages/settings/mod.rs index ff25f01..0dad606 100644 --- a/crates/app-iced/src/pages/settings/mod.rs +++ b/crates/app-iced/src/pages/settings/mod.rs @@ -138,6 +138,7 @@ pub(crate) mod java; pub(crate) mod language_appearance; mod logs_row; mod message; +mod registry; mod state; pub(crate) mod update; @@ -147,21 +148,51 @@ mod tests; pub use message::GroupMessage; pub use state::{demo_groups, Groups, States}; -use iced::widget::column; +use iced::widget::{button, column}; use iced::{Element, Length}; use unlucky_design::color::Role; -use unlucky_design::icons::IconKind; use unlucky_design::spacing::Space; use unlucky_design::typography::TypeRole; use unlucky_i18n::{Lang, LangChoice}; +use crate::app::components::buttons::Button; +use crate::app::components::interaction::Interaction; use crate::theme::Ui; use super::layout::{FrameMetrics, PageLayout}; use super::update::Update; use super::Message; +pub(super) use registry::Group; +use registry::{AI_GROUP, GENERAL_GROUP, GROUPS, GROUP_LABELS, JAVA_GROUP, SELECTED, UPDATE_GROUP}; + +/// 这一帧某个控件**实际要画**的交互态:`forced`(`--force-state`)优先,否则退回观测到的 `status`。 +/// +/// **全仓唯一的 resolve 点(M285)**:语言档 / 主题档 / 日志 `action_button` / 外观 `choice` 一律调它 +/// ——各自再写一遍 `unwrap_or_else` 就是把同一行语义复制四份,下次只改三处就等着漂。语义与 +/// `pages/instances.rs` 的 `forced_state` 相同(`Some` 覆盖、`None` 不动),只是裸 `iced::button` 没有 +/// `.state()`,覆盖只能发生在样式闭包**里面**(换掉它拿到的那一态)。 +pub(super) fn resolve_state(forced: Option, status: button::Status) -> Interaction { + forced.unwrap_or_else(|| Interaction::from_status(status)) +} + +/// 把一个 `components::buttons::Button` 钉到 `forced` 那一态(`pages/instances.rs` 的 `forced_state` +/// 在设置页的这一份):`Some` ⇒ `.state(..)`,`None` ⇒ 原样返回。 +/// +/// ⚠️ **优先级是 `state` 赢**(`Button::forced()` = `self.state.or(没有消息 ⇒ Disabled)`,`state` 在前) +/// ⇒ `forced_button(Button::disabled(..), Some(Hovered))` 会把禁用按钮**画成 hover**。今天 7 个站点都 +/// 写在**启用分支里**所以安全,**别**套到 `if..else` 外面。第二份不是抽象:三次法则到 2。 +pub(super) fn forced_button<'a, M>( + button: Button<'a, M>, + forced: Option, +) -> Button<'a, M> { + match forced { + Some(state) => button.state(state), + None => button, + } +} + /// 设置页 General 分组要的两件(M106):用户选的档位 + 这一次生效的语言。 /// /// 为什么不塞进 `pages::Model`:那是页面与 presenter 之间的**展示契约**(由快照派生),而档位是 @@ -187,103 +218,20 @@ impl Default for Language { } } -/// 一个分组正文的**真控件渲染器**:入参是「画一帧时那份只读状态」(组状态 + 外观状态) -/// 与**这一次生效的语言**,返回 `None` = 这一组现在没东西可画(例如外观分组在 harness 路径下 -/// 没有外观状态),由 [`form`] 退回演示行。 -/// -/// 各组自己的渲染器**定义在自己文件里**,签名是这一条:于是「这一组要读哪些状态」由它自己决定, -/// 本文件不需要认识任何一组的字段(r1 复审 P1 要的正是这一点)。 -/// -/// `lang` 是 M179 加的:槽位此前拿不到语言,唯一挂了真控件的外观组只能写死英文;接进**唯一的那个 -/// 槽位**比再给 [`form`] 加一条按下标分发的 `else if` 更省行数(本文件贴着 400 行硬顶)。 -type Body = for<'a> fn(&'a Ui, States<'a>, Lang) -> Option>; - -/// 一个设置分组:名字、图标、演示行、(可选)把这一组正文画成真控件的渲染器。 -/// -/// 除 `name`/`icon`/`rows`/`body` 外没有别的字段:**分组自己的状态归分组自己**(见 [`state`]), -/// 这里只装「怎么画」。 -struct Group { - /// 左列显示名(也是表单列的分组标题)。 - name: &'static str, - /// 左列图标。 - icon: IconKind, - /// 演示行(`body` 为 `None` 时画这些;harness 出图也靠它们)。 - rows: &'static [(&'static str, &'static str)], - /// 真控件渲染器;`None` = 这一组还没接真控件,画 `rows`。 - body: Option, -} - -/// 分组注册表:**顺序 = 左列自上而下**,也是 `crate::pages::appearance::APPEARANCE_GROUP` 的下标来源。 -/// -/// 每一项都指向它自己文件里的 `GROUP`(M99 之前是四份字面量挤在本文件里)。 -const GROUPS: [Group; 6] = [ - general::GROUP, - appearance::GROUP, - java::GROUP, - download::GROUP, - // M159:自更新只**追加**在末尾(插在中间会让 `APPEARANCE_GROUP` / `GENERAL_GROUP` 两个下标 - // 全部失效,`tests/mod.rs` 的「左列顺序」那条也会红)。 - update::GROUP, - // M174:助手(AI)配置。同样只追加(下标 = `ai_about::AI_GROUP`),正文走按下标分发。 - ai_about::GROUP, -]; - -/// 演示选中的分组(没有真实外观状态时——`pages_demo` harness——用它)。 -const SELECTED: usize = 0; - -/// General 分组在 [`GROUPS`] 里的下标(0)。它与 [`SELECTED`] 同值**是巧合,不是依赖**。 -/// -/// 为什么需要它:语言行要的是「当前档位 + 生效语言」([`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; - -/// 左列分组的**显示名** key(下标与 [`GROUPS`] 一一对应,M106)。 -/// -/// 为什么不放进 [`Group`]:那会让另外三组的 `GROUP` 字面量(各自文件、各自填页线)编译不过—— -/// 与 [`GENERAL_GROUP`] 同一条理由。`Group.name` 因此退化为**id**(测试按它认组;显示走这张表)。 -pub(super) const GROUP_LABELS: [unlucky_i18n::Key; 6] = [ - unlucky_i18n::Key::SettingsGroupGeneral, - unlucky_i18n::Key::SettingsGroupAppearance, - unlucky_i18n::Key::SettingsGroupJava, - unlucky_i18n::Key::SettingsGroupDownloads, - // M159 - unlucky_i18n::Key::SettingsGroupUpdate, - // M174 - unlucky_i18n::Key::SettingsGroupAssistant, -]; - -/// 「更新」分组在 [`GROUPS`] 里的下标(真源在 `settings/update.rs`,这里只转发)。 -/// -/// 与 [`GENERAL_GROUP`] 同一种做法:这一组要画的是 **presenter 快照**(不在 [`States`] 里), -/// 所以由 `view` 的 `update` 形参一路递到下标的 [`form`] 分发点,`GROUP.body` 保持 `None`。 -const UPDATE_GROUP: usize = update::UPDATE_GROUP; - -/// 「助手(AI)」分组在 [`GROUPS`] 里的下标(真源在 `settings/ai_about.rs`,这里只转发)。 -/// -/// 与 [`GENERAL_GROUP`] / [`UPDATE_GROUP`] 同一条做法:由 `view` 的 `language` 形参递到 [`form`] -/// 按本下标分发,`GROUP.body` 保持 `None`(本组**尚未**迁到 [`Body`] 槽位:槽位现在带 `Lang` 了, -/// 迁它要把 `ai::view::body` 的返回值改成 `Option`,是一次独立重构——M179 不做)。 -const AI_GROUP: usize = ai_about::AI_GROUP; - -/// 「Java」分组在 [`GROUPS`] 里的下标(真源在 `settings/java.rs`,这里只转发)。 -/// -/// M175 起这一组也按下标分发:它要 `Lang`(中英文案)并且要读后台扫描的快照 -/// (扫描线程不碰 `App`,结论由心跳灌进 `Groups.java`,页面只从 [`States`] 里取)。 -const JAVA_GROUP: usize = java::JAVA_GROUP; - /// 整页。 /// /// `states` 是「画一帧时那份只读状态」:[`States::appearance`] 是 M93 接进来的外观运行期状态 /// (`None` = harness / 演示路径,设置页仍画占位行),[`States::groups`] 是各组自己的状态。 +/// +/// `forced` 是 `pages::Model::forced`(`--force-state`,M285):一路递给各组控件,由 [`resolve_state`] +/// 决定「这一帧画哪一态」。`None` = 照旧按观测态画(产品路径恒为 `None`,取证启动除外)⇒ 默认逐字节不变。 pub fn view<'a>( ui: &'a Ui, metrics: FrameMetrics, states: States<'a>, language: Language, update: &'a Update, + forced: Option, ) -> Element<'a, Message> { // 选中分组:有真实外观状态时看状态(点左列可切),否则退回演示值 0。 let selected = states @@ -296,10 +244,10 @@ pub fn view<'a>( frame::group_nav(ui, selected, language.effective), frame::left_aligned( metrics.column, - form(ui, Some(selected), states, language, update), + form(ui, Some(selected), states, language, update, forced), ), ), - _ => frame::single_column(metrics, form(ui, None, states, language, update)), + _ => frame::single_column(metrics, form(ui, None, states, language, update, forced)), }; frame::page(ui, metrics, frame::header(ui, language.effective), body) @@ -324,10 +272,11 @@ fn form<'a>( states: States<'a>, language: Language, update: &'a Update, + forced: Option, ) -> Element<'a, Message> { let mut stack = column![].spacing(ui.space(Space::X6)).width(Length::Fill); - for section in section_columns(ui, only, states, language, update) { + for section in section_columns(ui, only, states, language, update, forced) { stack = stack.push(section); } @@ -342,6 +291,7 @@ fn section_columns<'a>( states: States<'a>, language: Language, update: &'a Update, + forced: Option, ) -> Vec> { let catalog = language.effective.catalog(); let mut columns = Vec::new(); @@ -358,20 +308,20 @@ fn section_columns<'a>( // 档位与生效语言不在 [`States`] 里,只能从 `pages::Language` 进来;它与 `appearance` 在不在场 // 无关(harness 那条路径也画得出真控件)。 let card = if index == GENERAL_GROUP { - general::body(ui, states, language) + general::body(ui, states, language, forced) } else if index == UPDATE_GROUP { // M159:更新组读 presenter 快照(`view` 的 `update` 形参),与 General 同一条按下标分发。 - update::body(ui, update, language.effective) + update::body(ui, update, language.effective, forced) } else if index == AI_GROUP { // M174:助手组要 i18n 文案与「测试连接」结论,与 General / Update 同一条按下标分发。 - ai::view::body(ui, states, language.effective) + ai::view::body(ui, states, language.effective, forced) } else if index == JAVA_GROUP { // M175:Java 组要 i18n 文案 + 扫描快照,同样按下标分发(`GROUP.body` 保持 `None`)。 - java::view::body(ui, states, language.effective) + java::view::body(ui, states, language.effective, forced) } else { group .body - .and_then(|body| body(ui, states, language.effective)) + .and_then(|body| body(ui, states, language.effective, forced)) .unwrap_or_else(|| frame::demo_card(ui, group.rows)) }; @@ -397,3 +347,51 @@ fn section_columns<'a>( columns } + +#[cfg(test)] +mod resolve_tests { + use super::*; + + /// 两条语义各钉一次(M285):`forced_button` 的 `Some` 钉态 / `None` 原样(**禁用态也会被 `state` + /// 盖掉**——`Button::forced()` 里 `state` 在 disabled 默认之前,钉住实际行为,别让站点踩)、 + /// `resolve_state` 的 `Some` 覆盖观测 status / `None` 退回观测值。`Button::state` 没有 getter, + /// 所以断 derived Debug 的那一格。抓不住:按钮真画成那色(出图负责)、站点传错 forced(只有像素)。 + #[test] + fn forced_button_forces_only_when_asked() { + let plain = Button::new("x", ()); + let forced = format!( + "{:?}", + forced_button(plain.clone(), Some(Interaction::Hovered)) + ); + assert!( + forced.contains("state: Some(Hovered)"), + "forced 必须钉到那一态:{forced}" + ); + let untouched = format!("{:?}", forced_button(plain, None)); + assert!( + untouched.contains("state: None"), + "没给 forced 时必须原样返回:{untouched}" + ); + // 禁用按钮同样会被钉态(`state` 覆盖 disabled 默认,类型层不挡): + let disabled: Button<'_, ()> = + forced_button(Button::disabled("x"), Some(Interaction::Hovered)); + assert!( + format!("{disabled:?}").contains("state: Some(Hovered)"), + "state 在 disabled 默认之前 ⇒ 禁用按钮也会被画成 hover/pressed" + ); + } + + #[test] + fn forced_state_wins_over_the_observed_status() { + assert_eq!( + resolve_state(Some(Interaction::Hovered), button::Status::Pressed), + Interaction::Hovered, + "forced 必须覆盖观测到的 status,否则 --force-state 在这四个控件上等于没接线" + ); + assert_eq!( + resolve_state(None, button::Status::Pressed), + Interaction::Pressed, + "没给 forced 时必须退回观测值(既有行为不变)" + ); + } +} diff --git a/crates/app-iced/src/pages/settings/registry.rs b/crates/app-iced/src/pages/settings/registry.rs new file mode 100644 index 0000000..268bf40 --- /dev/null +++ b/crates/app-iced/src/pages/settings/registry.rs @@ -0,0 +1,121 @@ +//! 设置页的**分组注册表**(M285 `GROUPSPLIT`:从 `settings/mod.rs` **纯搬移**)。 +//! +//! 本文件只装「有哪些分组、怎么画一组」这一份数据:正文渲染器 [`Body`]、分组形状 [`Group`]、 +//! 顺序表 [`GROUPS`]、左列显示名 [`GROUP_LABELS`],以及按下标分发用的那几个下标常量。 +//! +//! ## 为什么切在这里 +//! +//! `settings/mod.rs` 撞了 400 行硬线(`components/gate.rs`),而 M285 要给 [`Body`] 加一个 +//! `forced: Option` 形参(`--force-state` 直通,见 `settings::resolve_state`)。 +//! 切分按**语义**走,不按行数:`mod.rs` 留「整页 → 表单列 → 分组正文」这条流水线,本文件留 +//! 「分组是什么」。搬移是逐字搬的——除了声明前面的可见性标注(见下),语句一个字没动;注释里', +//! 说的「本文件」仍指 `settings/mod.rs`(那些句子说的是**原来那个文件**的行数压力),别读成本文件。 +//! +//! ## 可见性 +//! +//! `registry` 是 `settings` 的**私有子模块**,所以这里写宽一点也出不去:[`Group`] / [`Body`] 是 +//! `pub(crate)`,因为 `settings/mod.rs` 要 `pub(super) use registry::Group`(E0365:再导出不能比原 +//! 声明更可见);其余常量是 `pub(super)`,由 `mod.rs` 私有 `use` 进来,子模块仍按既有写法 +//! `super::GROUPS` / `super::GROUP_LABELS` 取用(私有 `use` 对后代模块可见)。 + +use iced::Element; + +use unlucky_design::icons::IconKind; +use unlucky_i18n::Lang; + +use crate::app::components::interaction::Interaction; +use crate::theme::Ui; + +use super::{ai_about, appearance, download, general, java, update, Message, States}; + +/// 一个分组正文的**真控件渲染器**:入参是「画一帧时那份只读状态」(组状态 + 外观状态) +/// 与**这一次生效的语言**,返回 `None` = 这一组现在没东西可画(例如外观分组在 harness 路径下 +/// 没有外观状态),由 [`super::form`] 退回演示行。 +/// +/// 各组自己的渲染器**定义在自己文件里**,签名是这一条:于是「这一组要读哪些状态」由它自己决定, +/// 本文件不需要认识任何一组的字段(r1 复审 P1 要的正是这一点)。 +/// +/// `lang` 是 M179 加的:槽位此前拿不到语言,唯一挂了真控件的外观组只能写死英文;接进**唯一的那个 +/// 槽位**比再给 [`super::form`] 加一条按下标分发的 `else if` 更省行数(本文件贴着 400 行硬顶)。 +/// +/// `forced` 是 M285 加的(`pages::Model::forced`,即 `--force-state`):`None` = 按控件自己的观测态画 +/// (产品路径的常态),`Some(..)` 只服务取证。站点拿它去调 [`super::resolve_state`]——那是全仓唯一的 +/// resolve 点,别在这里或各组里另写一遍。 +pub(crate) type Body = + for<'a> fn(&'a Ui, States<'a>, Lang, Option) -> Option>; + +/// 一个设置分组:名字、图标、演示行、(可选)把这一组正文画成真控件的渲染器。 +/// +/// 除 `name`/`icon`/`rows`/`body` 外没有别的字段:**分组自己的状态归分组自己**(见 [`super::state`]), +/// 这里只装「怎么画」。 +pub(crate) struct Group { + /// 左列显示名(也是表单列的分组标题)。 + pub(super) name: &'static str, + /// 左列图标。 + pub(super) icon: IconKind, + /// 演示行(`body` 为 `None` 时画这些;harness 出图也靠它们)。 + pub(super) rows: &'static [(&'static str, &'static str)], + /// 真控件渲染器;`None` = 这一组还没接真控件,画 `rows`。 + pub(super) body: Option, +} + +/// 分组注册表:**顺序 = 左列自上而下**,也是 `crate::pages::appearance::APPEARANCE_GROUP` 的下标来源。 +/// +/// 每一项都指向它自己文件里的 `GROUP`(M99 之前是四份字面量挤在本文件里)。 +pub(super) const GROUPS: [Group; 6] = [ + general::GROUP, + appearance::GROUP, + java::GROUP, + download::GROUP, + // M159:自更新只**追加**在末尾(插在中间会让 `APPEARANCE_GROUP` / `GENERAL_GROUP` 两个下标 + // 全部失效,`tests/mod.rs` 的「左列顺序」那条也会红)。 + update::GROUP, + // M174:助手(AI)配置。同样只追加(下标 = `ai_about::AI_GROUP`),正文走按下标分发。 + ai_about::GROUP, +]; + +/// 演示选中的分组(没有真实外观状态时——`pages_demo` harness——用它)。 +pub(super) const SELECTED: usize = 0; + +/// General 分组在 [`GROUPS`] 里的下标(0)。它与 [`SELECTED`] 同值**是巧合,不是依赖**。 +/// +/// 为什么需要它:语言行要的是「当前档位 + 生效语言」([`super::Language`]),而 `Group.body` 只带 +/// [`States`] 与**生效语言**(`Lang`,M179)——档位 `LangChoice` 装不进去(`States` 也装不下: +/// 加字段要动 `settings/state.rs` 与 4 处 struct literal,见本文件文档「仍然共享」第 4 条)。 +/// 所以这里做一次**按下标的分发**:代价是一行 `if`,换来 `general::GROUP.body` 仍是 `None` +/// (`tests/mod.rs` 的「只有外观组带真控件 body」照样成立)。 +pub(super) const GENERAL_GROUP: usize = 0; + +/// 左列分组的**显示名** key(下标与 [`GROUPS`] 一一对应,M106)。 +/// +/// 为什么不放进 [`Group`]:那会让另外三组的 `GROUP` 字面量(各自文件、各自填页线)编译不过—— +/// 与 [`GENERAL_GROUP`] 同一条理由。`Group.name` 因此退化为**id**(测试按它认组;显示走这张表)。 +pub(super) const GROUP_LABELS: [unlucky_i18n::Key; 6] = [ + unlucky_i18n::Key::SettingsGroupGeneral, + unlucky_i18n::Key::SettingsGroupAppearance, + unlucky_i18n::Key::SettingsGroupJava, + unlucky_i18n::Key::SettingsGroupDownloads, + // M159 + unlucky_i18n::Key::SettingsGroupUpdate, + // M174 + unlucky_i18n::Key::SettingsGroupAssistant, +]; + +/// 「更新」分组在 [`GROUPS`] 里的下标(真源在 `settings/update.rs`,这里只转发)。 +/// +/// 与 [`GENERAL_GROUP`] 同一种做法:这一组要画的是 **presenter 快照**(不在 [`States`] 里), +/// 所以由 `view` 的 `update` 形参一路递到下标的 [`super::form`] 分发点,`GROUP.body` 保持 `None`。 +pub(super) const UPDATE_GROUP: usize = update::UPDATE_GROUP; + +/// 「助手(AI)」分组在 [`GROUPS`] 里的下标(真源在 `settings/ai_about.rs`,这里只转发)。 +/// +/// 与 [`GENERAL_GROUP`] / [`UPDATE_GROUP`] 同一条做法:由 `view` 的 `language` 形参递到 [`super::form`] +/// 按本下标分发,`GROUP.body` 保持 `None`(本组**尚未**迁到 [`Body`] 槽位:槽位现在带 `Lang` 了, +/// 迁它要把 `ai::view::body` 的返回值改成 `Option`,是一次独立重构——M179 不做)。 +pub(super) const AI_GROUP: usize = ai_about::AI_GROUP; + +/// 「Java」分组在 [`GROUPS`] 里的下标(真源在 `settings/java.rs`,这里只转发)。 +/// +/// M175 起这一组也按下标分发:它要 `Lang`(中英文案)并且要读后台扫描的快照 +/// (扫描线程不碰 `App`,结论由心跳灌进 `Groups.java`,页面只从 [`States`] 里取)。 +pub(super) const JAVA_GROUP: usize = java::JAVA_GROUP; 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 d628be1..1f87790 100644 --- a/crates/app-iced/src/pages/settings/tests/appearance_section.rs +++ b/crates/app-iced/src/pages/settings/tests/appearance_section.rs @@ -161,6 +161,7 @@ fn appearance_texts(lang: Lang) -> Vec<(Key, &'static str)> { effective: lang, }, &update, + None, ); probe::take() diff --git a/crates/app-iced/src/pages/settings/update.rs b/crates/app-iced/src/pages/settings/update.rs index 98bdf78..5130e5d 100644 --- a/crates/app-iced/src/pages/settings/update.rs +++ b/crates/app-iced/src/pages/settings/update.rs @@ -29,6 +29,7 @@ use unlucky_i18n::{Catalog, Key, Lang}; use unlucky_presenter::dto::human_bytes; use crate::app::components::buttons::{self, Button}; +use crate::app::components::interaction::Interaction; use crate::app::components::selection::{chip, Chip}; use crate::pages::frame::text_role_owned; @@ -38,7 +39,7 @@ use crate::pages::UpdateAction; use crate::theme::Ui; use super::frame::{card, divider}; -use super::Group; +use super::{forced_button, Group}; /// 「更新」分组在 [`super::GROUPS`] 里的下标(**必须是最后一个**:加组只追加不重排)。 pub(super) const UPDATE_GROUP: usize = 4; @@ -57,7 +58,12 @@ pub(super) const GROUP: Group = Group { }; /// 渲染整组正文。 -pub(super) fn body<'a>(ui: &'a Ui, update: &'a Update, lang: Lang) -> Element<'a, Message> { +pub(super) fn body<'a>( + ui: &'a Ui, + update: &'a Update, + lang: Lang, + forced: Option, +) -> Element<'a, Message> { let catalog = lang.catalog(); let mut stacked = column![].spacing(0.0).width(Length::Fill); @@ -73,9 +79,9 @@ pub(super) fn body<'a>(ui: &'a Ui, update: &'a Update, lang: Lang) -> Element<'a channel_label(catalog, &update.channel), )); stacked = stacked.push(divider(ui)); - stacked = stacked.push(auto_check_row(ui, catalog, update)); + stacked = stacked.push(auto_check_row(ui, catalog, update, forced)); stacked = stacked.push(divider(ui)); - stacked = stacked.push(status_block(ui, catalog, update)); + stacked = stacked.push(status_block(ui, catalog, update, forced)); card(ui, stacked) } @@ -121,12 +127,20 @@ fn info_row<'a>(ui: &'a Ui, label: &'a str, value: String) -> Element<'a, Messag /// **忙碌时「立即检查」置灰**(M161):此前它永远可点,而 presenter 的 `begin_check` 在 /// inflight 时静默 `return None`——用户点了没反应,还以为启动器卡了。禁用态与状态行同源 /// (同一个 [`Update::busy`]):按钮灰着的时候,下面那句状态行一定是「正在检查…」/「正在安装…」。 -fn auto_check_row<'a>(ui: &'a Ui, catalog: Catalog, update: &Update) -> Element<'a, Message> { +fn auto_check_row<'a>( + ui: &'a Ui, + catalog: Catalog, + update: &Update, + forced: Option, +) -> Element<'a, Message> { let check_now = catalog.text(Key::SettingsUpdateCheckNow); let check = if update.can_check() { buttons::text( ui, - Button::new(check_now, Message::Update(UpdateAction::CheckNow)), + forced_button( + Button::new(check_now, Message::Update(UpdateAction::CheckNow)), + forced, + ), ) } else { buttons::text(ui, Button::disabled(check_now)) @@ -150,7 +164,12 @@ fn auto_check_row<'a>(ui: &'a Ui, catalog: Catalog, update: &Update) -> Element< } /// 上次检查时间 + 结论 + (有候选时)下载按钮 + 说明摘要 + 一句校验提示。 -fn status_block<'a>(ui: &'a Ui, catalog: Catalog, update: &'a Update) -> Element<'a, Message> { +fn status_block<'a>( + ui: &'a Ui, + catalog: Catalog, + update: &'a Update, + forced: Option, +) -> Element<'a, Message> { let mut block = column![].spacing(ui.space(Space::X2)).width(Length::Fill); let checked = if update.checked_at.trim().is_empty() { @@ -174,14 +193,17 @@ fn status_block<'a>(ui: &'a Ui, catalog: Catalog, update: &'a Update) -> Element }); block = block.push(text_role_owned(ui, TypeRole::BodySmall, false, text, color)); - if let Some((_version, _size, notes, forced)) = update.available() { + if let Some((_version, _size, notes, forced_update)) = update.available() { // 有候选就**永远画出**这一颗按钮:忙碌时置灰(`can_apply` = false),而不是整颗消失。 // 消失会让人以为「装到一半的功能没了」;灰着 + 状态行那句「正在安装…」才说得清。 let apply_label = catalog.text(Key::SettingsUpdateApply); let apply = if update.can_apply() { buttons::filled( ui, - Button::new(apply_label, Message::Update(UpdateAction::Apply)), + forced_button( + Button::new(apply_label, Message::Update(UpdateAction::Apply)), + forced, + ), ) } else { buttons::filled(ui, Button::disabled(apply_label)) @@ -189,7 +211,7 @@ fn status_block<'a>(ui: &'a Ui, catalog: Catalog, update: &'a Update) -> Element let mut actions = row![apply] .spacing(ui.space(Space::X2)) .align_y(Alignment::Center); - if forced { + if forced_update { actions = actions.push(text_role_owned( ui, TypeRole::BodySmall, @@ -220,9 +242,12 @@ fn status_block<'a>(ui: &'a Ui, catalog: Catalog, update: &'a Update) -> Element )); block = block.push(buttons::tonal( ui, - Button::new( - catalog.text(Key::UpdateBannerRestartNow), - Message::Update(UpdateAction::RestartNow), + forced_button( + Button::new( + catalog.text(Key::UpdateBannerRestartNow), + Message::Update(UpdateAction::RestartNow), + ), + forced, ), )); } diff --git a/crates/app-iced/src/pages/tests/base_wiring.rs b/crates/app-iced/src/pages/tests/base_wiring.rs index 0743b77..bc39fe3 100644 --- a/crates/app-iced/src/pages/tests/base_wiring.rs +++ b/crates/app-iced/src/pages/tests/base_wiring.rs @@ -42,7 +42,7 @@ const WIRED: [(&str, usize); 9] = [ /// 手写样式(不走组件 `.base(..)`)的三处:状态层直接叠在 `frame::BASE.role()` 上。 const ROLE_SITES: [(&str, usize); 3] = [ - ("settings/general.rs", 1), + ("settings/general/language.rs", 1), ("settings/general/theme.rs", 1), ("settings/logs_row.rs", 1), ]; @@ -88,8 +88,8 @@ fn every_card_internal_control_site_still_passes_a_base() { include_str!("../settings/java/download_view.rs"), ), ( - "settings/general.rs", - include_str!("../settings/general.rs"), + "settings/general/language.rs", + include_str!("../settings/general/language.rs"), ), ( "settings/general/theme.rs",