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/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/general.rs b/crates/app-iced/src/pages/settings/general.rs index e13c643..0b6d03a 100644 --- a/crates/app-iced/src/pages/settings/general.rs +++ b/crates/app-iced/src/pages/settings/general.rs @@ -47,6 +47,7 @@ use unlucky_design::spacing::Space; use unlucky_design::typography::TypeRole; use unlucky_i18n::catalog::Key; +use crate::app::components::interaction::Interaction; use crate::fonts::install::{CjkInstall, FetchClass}; use crate::pages::{frame, Language, Message}; use crate::theme::Ui; @@ -72,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::view(ui, language)); + stacked = stacked.push(language::view(ui, language, forced)); // M168(D28 后半):中文字体的下载状态行。紧贴语言行——用户在这里手选中文,反馈就该在这行下面, // 而不是等他切到别的页面才发现「刚才那几秒在干嘛」。 stacked = stacked.push(divider(ui)); @@ -88,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 [ diff --git a/crates/app-iced/src/pages/settings/general/language.rs b/crates/app-iced/src/pages/settings/general/language.rs index 3db2559..5d1980e 100644 --- a/crates/app-iced/src/pages/settings/general/language.rs +++ b/crates/app-iced/src/pages/settings/general/language.rs @@ -24,7 +24,11 @@ use crate::pages::{frame, Language, Message}; use crate::theme::Ui; /// 语言行:标题 + 生效语言回显 + 三档互斥按钮 + 一句说明。 -pub(super) fn view<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> { +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(); @@ -60,6 +64,7 @@ pub(super) fn view<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> { catalog.text(choice.label_key()), *choice == language.choice, *choice, + forced, ) }) .collect::>()) @@ -90,7 +95,13 @@ pub(super) fn view<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> { /// **几何(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> { +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 { @@ -124,6 +135,7 @@ fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, choice: LangChoice) -> El border_color, stroke, radius, + forced, )) .on_press(Message::Language(choice)) .into() @@ -131,16 +143,20 @@ fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, choice: LangChoice) -> El /// [`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` 里,状态层叠卡底而不是画布)。 -fn chip_style<'a>( +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 { @@ -148,7 +164,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(), ) 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 ab7f720..a4b998f 100644 --- a/crates/app-iced/src/pages/settings/mod.rs +++ b/crates/app-iced/src/pages/settings/mod.rs @@ -148,7 +148,7 @@ 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; @@ -156,6 +156,8 @@ 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}; @@ -165,6 +167,36 @@ 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`(`Button::state(…)`,`Some` 覆盖、`None` 不动)相同; +/// 差别只在 iced 的裸 `button` 没有 `.state()`:样式闭包的签名固定收 `button::Status`,所以覆盖只能 +/// 发生在闭包**里面**(换掉它拿到的那一态),而不是控件上。 +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` +/// 在设置页的这一份)。 +/// +/// **这是第二份,不是抽象**(M285):实例页那条线的控件本身就是 `components::buttons::Button` +///(有 `state(Interaction)`),而设置页的 `choice` / `chip` / `action_button` 是裸 `iced::button`—— +/// 覆盖只能落在样式闭包里(见 [`resolve_state`])。三次法则现在到 2;第三次真出现时落点是 +/// `components/interaction/`,不是这里。 +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 之间的**展示契约**(由快照派生),而档位是 @@ -194,12 +226,17 @@ impl Default for Language { /// /// `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 @@ -212,10 +249,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) @@ -240,10 +277,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); } @@ -258,6 +296,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(); @@ -274,20 +313,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)) }; @@ -313,3 +352,50 @@ fn section_columns<'a>( columns } + +#[cfg(test)] +mod resolve_tests { + use super::*; + + /// `forced` 必须**覆盖**观测到的 status;不给 `forced` 时必须**退回**观测值(M285)。 + /// + /// 抓得住:把 `Some` 分支改成忽略 forced(直接 `Interaction::from_status(status)`)——第一条立刻红, + /// 而那正好等于「`--force-state` 在四个控件上没接线」。 + /// 抓不住:forced 真的画进了 PNG(那是 `pages_demo --force-state=hover|pressed` 的像素判据)。 + /// `forced_button` 只做一件事:`Some` 钉态、`None` 原样(M285)。 + /// + /// `Button::state` 是私有字段、没有 getter,所以断 **derived Debug** 里的那一格。 + /// 抓得住:`Some` 分支被改成恒 `None`(第一条红);`None` 分支也顺手 `.state(..)`(第二条红)。 + /// 抓不住:按钮真画成那色(那是出图的事)。 + #[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}" + ); + } + + #[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 index 6124528..268bf40 100644 --- a/crates/app-iced/src/pages/settings/registry.rs +++ b/crates/app-iced/src/pages/settings/registry.rs @@ -23,6 +23,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::{ai_about, appearance, download, general, java, update, Message, States}; @@ -36,7 +37,12 @@ use super::{ai_about, appearance, download, general, java, update, Message, Stat /// /// `lang` 是 M179 加的:槽位此前拿不到语言,唯一挂了真控件的外观组只能写死英文;接进**唯一的那个 /// 槽位**比再给 [`super::form`] 加一条按下标分发的 `else if` 更省行数(本文件贴着 400 行硬顶)。 -pub(crate) type Body = for<'a> fn(&'a Ui, States<'a>, Lang) -> Option>; +/// +/// `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>; /// 一个设置分组:名字、图标、演示行、(可选)把这一组正文画成真控件的渲染器。 /// 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, ), )); }