From 589162de89702a86b8b29b4751732d2234376d04 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 12 Nov 2026 22:02:23 +0800 Subject: [PATCH 1/8] =?UTF-8?q?refactor(app-iced):=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E5=88=86=E7=BB=84=E6=B3=A8=E5=86=8C=E8=A1=A8=E7=BA=AF?= =?UTF-8?q?=E6=90=AC=E7=A7=BB=E5=88=B0=20settings/registry.rs=EF=BC=88M285?= =?UTF-8?q?=20=E5=89=8D=E7=BD=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit settings/mod.rs 撞 400 行硬线,而 M285 要给 Group.body 加 forced 形参。 按语义切分(不按行数):mod.rs 留「整页 → 表单列 → 分组正文」流水线, registry.rs 留「分组是什么」—— Body / Group / GROUPS / GROUP_LABELS / SELECTED / GENERAL_GROUP / UPDATE_GROUP / AI_GROUP / JAVA_GROUP。 行为零变化:搬移块去缩进去可见性后与 mod.rs:190-275 逐字节相同 (diff -u 退出码 0,见 mission 报告)。可见性只有两类改动: - Group / Body 用 pub(crate),因为 mod.rs 要 pub(super) use(E0365:再导出 不能比原声明更可见); - 其余常量 pub(super),mod.rs 私有 use,子模块的 super::GROUPS 写法照旧可用。 --- crates/app-iced/src/pages/settings/mod.rs | 92 +------------- .../app-iced/src/pages/settings/registry.rs | 115 ++++++++++++++++++ 2 files changed, 119 insertions(+), 88 deletions(-) create mode 100644 crates/app-iced/src/pages/settings/registry.rs diff --git a/crates/app-iced/src/pages/settings/mod.rs b/crates/app-iced/src/pages/settings/mod.rs index ff25f01..ab7f720 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; @@ -151,7 +152,6 @@ use iced::widget::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}; @@ -162,6 +162,9 @@ 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}; + /// 设置页 General 分组要的两件(M106):用户选的档位 + 这一次生效的语言。 /// /// 为什么不塞进 `pages::Model`:那是页面与 presenter 之间的**展示契约**(由快照派生),而档位是 @@ -187,93 +190,6 @@ 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 接进来的外观运行期状态 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..6124528 --- /dev/null +++ b/crates/app-iced/src/pages/settings/registry.rs @@ -0,0 +1,115 @@ +//! 设置页的**分组注册表**(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::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 行硬顶)。 +pub(crate) type Body = for<'a> fn(&'a Ui, States<'a>, Lang) -> 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; From 767727efb79dd05c4c5aec36296feb391a3e3231 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 12 Nov 2026 22:04:10 +0800 Subject: [PATCH 2/8] =?UTF-8?q?refactor(app-iced):=20=E8=AF=AD=E8=A8=80?= =?UTF-8?q?=E8=A1=8C=E4=BB=8E=20general.rs=20=E7=BA=AF=E6=90=AC=E7=A7=BB?= =?UTF-8?q?=E5=88=B0=20general/language.rs=EF=BC=88M285=20=E5=89=8D?= =?UTF-8?q?=E7=BD=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit general.rs 390 行、撞 400 行硬线,而 M285 要给语言行三枚 chip 接 forced。 切法与既有的 general/theme.rs 同构(一个行控件一个文件):正文 view + chip + chip_style。语句逐字节搬移(去缩进去可见性后 diff 退出码 0),只把 fn language_row 改名成 pub(super) fn view(与 theme::view 同一入口命名)。 general.rs 390 → 245 行;language.rs 170 行。 --- crates/app-iced/src/pages/settings/general.rs | 158 +--------------- .../src/pages/settings/general/language.rs | 170 ++++++++++++++++++ 2 files changed, 174 insertions(+), 154 deletions(-) create mode 100644 crates/app-iced/src/pages/settings/general/language.rs diff --git a/crates/app-iced/src/pages/settings/general.rs b/crates/app-iced/src/pages/settings/general.rs index de136a8..e13c643 100644 --- a/crates/app-iced/src/pages/settings/general.rs +++ b/crates/app-iced/src/pages/settings/general.rs @@ -38,20 +38,15 @@ //! //! 分组元数据只有一份(这里的 [`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::fonts::install::{CjkInstall, FetchClass}; use crate::pages::{frame, Language, Message}; use crate::theme::Ui; @@ -59,6 +54,7 @@ use crate::theme::Ui; use super::frame::{card, divider}; use super::{Group, States}; +mod language; mod theme; /// General 分组(左列名固定英文:左列是**导航**,与导航轨同源,不进 catalog)。 @@ -79,7 +75,7 @@ pub(super) const GROUP: Group = Group { pub(super) fn body<'a>(ui: &'a Ui, states: States<'a>, language: Language) -> 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)); // M168(D28 后半):中文字体的下载状态行。紧贴语言行——用户在这里手选中文,反馈就该在这行下面, // 而不是等他切到别的页面才发现「刚才那几秒在干嘛」。 stacked = stacked.push(divider(ui)); @@ -215,66 +211,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 +238,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..3db2559 --- /dev/null +++ b/crates/app-iced/src/pages/settings/general/language.rs @@ -0,0 +1,170 @@ +//! 设置页 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) -> 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() +} + +/// 一个互斥按钮(选中态 `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, + } + } +} From 7b80b0e314589eba1c409a86d183d9aa3a4da994 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 12 Nov 2026 22:16:27 +0800 Subject: [PATCH 3/8] =?UTF-8?q?feat(app-iced):=20--force-state=20=E7=A9=BF?= =?UTF-8?q?=E8=BF=87=E8=AE=BE=E7=BD=AE=E9=A1=B5=E4=B8=8E=E5=A4=96=E8=A7=82?= =?UTF-8?q?=E5=8C=BA=EF=BC=88M285=20FORCEDWIRE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pages::Model.forced 此前只到得了外壳 + 实例页(唯一消费者 pages/instances.rs:159), 设置页「外观」区的滑块/文本框/裸 iced::button 全都不消费它 ⇒ M284 那四处状态层 (语言档 / 主题档 / 日志 action_button / 外观 choice)只有单测证据,做不出像素判据。 改动: - settings/mod.rs: resolve_state(forced, status) 与 forced_button(button, forced) 成为全仓唯一两个「forced 怎么解释」的点;view → form → section_columns 一路 新增 forced 形参(默认 None ⇒ 今天的行为逐字节不变)。 - Group.body(registry.rs)加第 4 个形参 Option,六组统一签名。 - 四类裸 iced::button 控件把 forced 递进样式闭包并经 resolve_state 落地: 语言档 chip(general/language.rs)、主题档 chip(general/theme.rs)、 日志 action_button(logs_row.rs)、外观 choice(appearance/section/mod.rs, 含 accent/window/bg_image/colour_source 共 10 处调用)。 - components::buttons::Button 的三组(update / ai / java)走 forced_button 的 .state()。 - harness: request.rs 解析 --force-state=rest|hover|pressed(未知值报错,词与产品 cli.rs 同一套),slug 加 -hover/-pressed;app.rs 把它写进 model.forced, 截图通道默认 Rest 的纪律保持(显式值优先)。 - pages/mod.rs: 唯一调用点把 model.forced 传给 settings::view(397 → 399 行)。 单测:resolve_state 的 Some 覆盖 / None 退回;forced_button 的 Some 钉态 / None 原样。 --- .../src/pages/appearance/section/accent.rs | 12 +- .../src/pages/appearance/section/bg_image.rs | 7 ++ .../pages/appearance/section/colour_source.rs | 5 + .../src/pages/appearance/section/mod.rs | 28 +++-- .../src/pages/appearance/section/window.rs | 4 + crates/app-iced/src/pages/harness/app.rs | 15 ++- crates/app-iced/src/pages/harness/request.rs | 37 ++++++- crates/app-iced/src/pages/mod.rs | 4 +- crates/app-iced/src/pages/settings/ai/view.rs | 44 ++++++-- .../app-iced/src/pages/settings/appearance.rs | 13 ++- .../app-iced/src/pages/settings/download.rs | 11 +- crates/app-iced/src/pages/settings/general.rs | 15 ++- .../src/pages/settings/general/language.rs | 24 +++- .../src/pages/settings/general/theme.rs | 31 +++++- .../app-iced/src/pages/settings/java/view.rs | 42 +++++-- .../app-iced/src/pages/settings/logs_row.rs | 21 +++- crates/app-iced/src/pages/settings/mod.rs | 104 ++++++++++++++++-- .../app-iced/src/pages/settings/registry.rs | 8 +- crates/app-iced/src/pages/settings/update.rs | 51 ++++++--- 19 files changed, 397 insertions(+), 79 deletions(-) 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, ), )); } From 20607a0584aeed199622154e91be637149e09cc2 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 12 Nov 2026 22:20:02 +0800 Subject: [PATCH 4/8] =?UTF-8?q?test(app-iced):=20=E4=B8=89=E5=A4=84?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E8=B0=83=E7=94=A8=E7=82=B9=E8=A1=A5=20forced?= =?UTF-8?q?=3DNone=EF=BC=88M285=20=E5=BD=A2=E5=8F=82=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit section_columns / settings::view / appearance::section 各加了 forced 形参, 测试里的调用点必须同步(同行情改,不增行;frame.rs 用一次局部绑定避免折行)。 frame.rs 393 → 394 行。产品逻辑零改动。 --- crates/app-iced/src/pages/appearance/tests.rs | 2 +- crates/app-iced/src/pages/settings/frame.rs | 5 +++-- .../app-iced/src/pages/settings/tests/appearance_section.rs | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) 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/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/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() From e15bd741cdd90fe3699ed19fdfb16cf29ee0503c Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 12 Nov 2026 22:33:56 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix(app-iced):=20=E5=AE=88=E4=BD=8F=20400?= =?UTF-8?q?=20=E8=A1=8C=20+=20=E6=8E=A5=E7=BA=BF=E8=AE=A1=E6=95=B0?= =?UTF-8?q?=E7=82=B9=E8=B7=9F=E7=9D=80=E8=AF=AD=E8=A8=80=E8=A1=8C=E6=90=AC?= =?UTF-8?q?=EF=BC=88M285=20=E9=97=A8=E7=A6=81=E7=BA=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - settings/mod.rs 401 → 396 行(压自己新加的文档,不删任何语义): components::gate::every_source_file_stays_under_the_line_limit 红过。 - pages/tests/base_wiring.rs:语言行搬进 general/language.rs 后,`BASE.role()` 的 计数点也要跟着搬(general.rs 0 次 → language.rs 1 次),否则 every_card_internal_control_site_still_passes_a_base 红。计数与语义都没变。 --- crates/app-iced/src/pages/settings/mod.rs | 23 ++++++++----------- .../app-iced/src/pages/tests/base_wiring.rs | 6 ++--- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/crates/app-iced/src/pages/settings/mod.rs b/crates/app-iced/src/pages/settings/mod.rs index a4b998f..c686b09 100644 --- a/crates/app-iced/src/pages/settings/mod.rs +++ b/crates/app-iced/src/pages/settings/mod.rs @@ -169,13 +169,10 @@ use registry::{AI_GROUP, GENERAL_GROUP, GROUPS, GROUP_LABELS, JAVA_GROUP, SELECT /// 这一帧某个控件**实际要画**的交互态:`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`,所以覆盖只能 -/// 发生在闭包**里面**(换掉它拿到的那一态),而不是控件上。 +/// **全仓唯一的 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)) } @@ -183,10 +180,9 @@ pub(super) fn resolve_state(forced: Option, status: button::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/`,不是这里。 +/// **这是第二份,不是抽象**(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, @@ -227,9 +223,8 @@ impl Default for Language { /// `states` 是「画一帧时那份只读状态」:[`States::appearance`] 是 M93 接进来的外观运行期状态 /// (`None` = harness / 演示路径,设置页仍画占位行),[`States::groups`] 是各组自己的状态。 /// -/// `forced` 是 `pages::Model::forced`(`--force-state` 的落点,M285):一路递到各组的控件,由 -/// [`resolve_state`] 决定「这一帧画哪一态」。`None` = 照旧按控件自己的观测态画——**产品路径恒为 -/// `None`**(除了取证启动),所以默认行为逐字节不变。 +/// `forced` 是 `pages::Model::forced`(`--force-state`,M285):一路递给各组控件,由 [`resolve_state`] +/// 决定「这一帧画哪一态」。`None` = 照旧按观测态画(产品路径恒为 `None`,取证启动除外)⇒ 默认逐字节不变。 pub fn view<'a>( ui: &'a Ui, metrics: FrameMetrics, 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", From 3255579bbafd60ff887162e9a48857fbee43ccb6 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 12 Nov 2026 22:54:01 +0800 Subject: [PATCH 6/8] =?UTF-8?q?docs(app-iced):=20=E6=8A=8A=E3=80=8C?= =?UTF-8?q?=E8=A3=B8=20button=20=E4=B8=8D=E6=B6=88=E8=B4=B9=20forced?= =?UTF-8?q?=E3=80=8D=E8=BF=99=E5=8F=A5=E5=81=87=E8=AF=9D=E6=94=B9=E6=8E=89?= =?UTF-8?q?=20+=20=E7=94=A8=E6=B3=95=E8=A1=A8=E8=A1=A5=20--force-state?= =?UTF-8?q?=EF=BC=88M285=20r1=20p2-1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审原话:harness.rs:43-45 那段「设置页外观区的滑块 / 文本框 / 裸 iced::button 都不消费 forced」**正是 M282/M284 放弃像素验收的原始理由**——M285 之后后半句已经是假的,留着它下一 个 agent 会再推一遍同一个死胡同。 - pages/harness.rs:参数清单补 --force-state=rest|hover|pressed;那段改成现状(裸 button 已 经消费:外观 11 处 choice + 语言/主题 chip + 日志 action_button 走 settings::resolve_state; components::buttons::Button 三组走 forced_button;仍不消费的只剩滑块 / 文本框 / 色块), 并明确「别把它改回去」。 - examples/pages_demo.rs:用法表补 --force-state / --appearance / --passthrough(此前 --force-state=bogus 先报约束、却打出一张没有该参数的用法表)。 实测:`--force-state=bogus` → exit 2,用法表里含 [--force-state=rest|hover|pressed]。 顺带 r1 p3-1(机制说法订正):forced_button 的文档写清真实优先级 —— Button::forced() 是 `self.state.or(没有消息 ⇒ Disabled)`,**state 在前**,所以 disabled 也能被 .state() 盖掉; 并加一条单测钉住实际结果(forced_button(Button::disabled(..), Some(Hovered)) ⇒ state: Some(Hovered))。今天 7 个站点都写在启用分支里所以安全,但**别**把它套到 if..else 外面。 行数:settings/mod.rs 396 → 397(先算过余量,未破 400);harness.rs 321 → 330; pages_demo.rs 37 → 39。判据 1 复验:重出 5 张 rest 图,md5 与基线逐张相同。 --- crates/app-iced/examples/pages_demo.rs | 4 +++- crates/app-iced/src/pages/harness.rs | 17 +++++++++---- crates/app-iced/src/pages/settings/mod.rs | 29 ++++++++++++----------- 3 files changed, 31 insertions(+), 19 deletions(-) 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/harness.rs b/crates/app-iced/src/pages/harness.rs index d0d1a32..4ca179d 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,17 @@ //! 改成 900×600,落盘的就是 900×600 那张)。现在闸门在 [`should_request_capture`],日志里的 //! `落盘次数=` 必须恒为 1。 //! -//! **为什么不是给每个控件灌 forced**:设置页「外观」区的滑块 / 文本框 / 裸 `iced::button` 都不 -//! 消费 forced,那样做要改十几个组件文件——为取证去动产品组件,代价与风险都不对,而且新产品 -//! 组件随时会漏。 +//! **forced 能到哪儿(M285 订正,原句已过时)**:`--force-state` 只改**画出来的**态,不改状态机。 +//! 它经 `pages::Model.forced` → `settings::view(.., forced)` → `Group.body` 递到各控件: +//! **裸 `iced::button` 已经消费**——外观区 11 处 `choice` + 语言 / 主题两类 chip + 日志两颗 +//! `action_button`,一律走 `settings::resolve_state`;`components::buttons::Button` 那三组 +//! (update / ai / java)走 `settings::forced_button` 的 `.state(..)`。**仍然不消费的**:外观区的 +//! 滑块 / 文本框 / 8 颗预设色块(`accent::swatch` 的描边闭包没有 forced 入口)——那几处要接, +//! 得先给对应的组件开口,不在本通道的账上。 +//! +//! ⚠️ 上一段在本 mission 之前写的是「裸 `iced::button` 都不消费 forced」——**那句正是 M282/M284 +//! 放弃像素验收的理由**,已经不成立:这四类的 hover / pressed 现在出得来像素证据(判据见 +//! M285 的 `DevDocs/artifacts/forcedwire/`)。别把它再改回去。 //! //! ```text //! cargo run -p unlucky-app-iced --example pages_demo -- --screen=instances --width=1180 \ diff --git a/crates/app-iced/src/pages/settings/mod.rs b/crates/app-iced/src/pages/settings/mod.rs index c686b09..0dad606 100644 --- a/crates/app-iced/src/pages/settings/mod.rs +++ b/crates/app-iced/src/pages/settings/mod.rs @@ -178,11 +178,11 @@ pub(super) fn resolve_state(forced: Option, status: button::Status) } /// 把一个 `components::buttons::Button` 钉到 `forced` 那一态(`pages/instances.rs` 的 `forced_state` -/// 在设置页的这一份)。 +/// 在设置页的这一份):`Some` ⇒ `.state(..)`,`None` ⇒ 原样返回。 /// -/// **这是第二份,不是抽象**(M285):实例页的控件本身就是 `components::buttons::Button`(有 -/// `state(Interaction)`),设置页的 `choice` / `chip` / `action_button` 是裸 `iced::button`(走 -/// [`resolve_state`])。三次法则现在到 2;第三次真出现时落点是 `components/interaction/`,不是这里。 +/// ⚠️ **优先级是 `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, @@ -352,16 +352,10 @@ fn section_columns<'a>( 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(..)`(第二条红)。 - /// 抓不住:按钮真画成那色(那是出图的事)。 + /// 两条语义各钉一次(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", ()); @@ -378,6 +372,13 @@ mod resolve_tests { 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] From a75ee465a70156e0d3a36920dd2d47c4cad3b65a Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 12 Nov 2026 23:03:30 +0800 Subject: [PATCH 7/8] =?UTF-8?q?docs(app-iced):=20harness=20=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E7=9A=84=E6=AE=8B=E4=BD=99=E6=B8=85=E5=8D=95=E6=94=B9?= =?UTF-8?q?=E5=86=99=EF=BC=88=E4=B8=89=E6=9D=A1=E4=B8=8E=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E4=B8=8D=E7=AC=A6=E7=9A=84=E5=8F=A5=E5=AD=90=EF=BC=8CM285=20r2?= =?UTF-8?q?=20p2-1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r2 复审逐句拿代码核过,三处不符: (a) 「裸 iced::button 已经消费」—— 8 颗预设色块本身就是裸 button(...) 且不消费(accent.rs:153/ 163-166,ACCENT_PRESETS: [&str; 8])⇒ 改成「这几类控件(choice / chip / action_button)已消费」。 (b) 残余清单漏了三类 ⇒ 补:Downloads 组整组(body 形参就是 _forced,switch/chip/两颗 Button::new 都不看 forced;实测 --screen=download 三态 md5 全等 e283c585…)、所有 components::selection 的 chip / switch 站点(update 自动检查 chip、ai provider/reveal chip 与开关、download 两处、java prefs_view 与 download_view)、java 的字段/预设/下载按钮(fields.rs / prefs_view.rs / download_view.rs)。同时修掉与本 mission 自己的 artifact README §1「下载页不消费 forced」的 互相打脸。 (c) 「要接得先给组件开口」对滑块/文本框是假的 ⇒ Slider::state / Field::state / Chip::state / Switch::state 全都存在且被消费(gallery 矩阵用的强制视觉态),没接是**没搭线**;真正缺 forced 入口的只有本地助手 accent::swatch。并加一句反向锚:别把「裸 button」当「已消费」的同义词。 纯文档,零接线改动。harness.rs 330 → 347 行。 --- crates/app-iced/src/pages/harness.rs | 37 ++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/crates/app-iced/src/pages/harness.rs b/crates/app-iced/src/pages/harness.rs index 4ca179d..03e2ed0 100644 --- a/crates/app-iced/src/pages/harness.rs +++ b/crates/app-iced/src/pages/harness.rs @@ -41,17 +41,34 @@ //! 改成 900×600,落盘的就是 900×600 那张)。现在闸门在 [`should_request_capture`],日志里的 //! `落盘次数=` 必须恒为 1。 //! -//! **forced 能到哪儿(M285 订正,原句已过时)**:`--force-state` 只改**画出来的**态,不改状态机。 -//! 它经 `pages::Model.forced` → `settings::view(.., forced)` → `Group.body` 递到各控件: -//! **裸 `iced::button` 已经消费**——外观区 11 处 `choice` + 语言 / 主题两类 chip + 日志两颗 -//! `action_button`,一律走 `settings::resolve_state`;`components::buttons::Button` 那三组 -//! (update / ai / java)走 `settings::forced_button` 的 `.state(..)`。**仍然不消费的**:外观区的 -//! 滑块 / 文本框 / 8 颗预设色块(`accent::swatch` 的描边闭包没有 forced 入口)——那几处要接, -//! 得先给对应的组件开口,不在本通道的账上。 +//! **forced 能到哪儿(M285 订正)**:`--force-state` 只改**画出来的**态,不改状态机。它经 +//! `pages::Model.forced` → `settings::view(.., forced)` → `Group.body` 递到各控件。 //! -//! ⚠️ 上一段在本 mission 之前写的是「裸 `iced::button` 都不消费 forced」——**那句正是 M282/M284 -//! 放弃像素验收的理由**,已经不成立:这四类的 hover / pressed 现在出得来像素证据(判据见 -//! M285 的 `DevDocs/artifacts/forcedwire/`)。别把它再改回去。 +//! **已经消费的**:外观区 11 处 `choice`、语言 3 枚 / 主题 2 枚 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 的两处、java 的 `prefs_view` 与 +//! `download_view`; +//! 4. java 的字段 / 预设 / 下载按钮(`java/fields.rs`、`java/prefs_view.rs`、`java/download_view.rs`)。 +//! +//! ⚠️ **别把「没搭线」说成「组件库缺能力」**:`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 \ From 5ce6d8567bf31e09065642c29a23eb9529fff787 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 12 Nov 2026 23:09:08 +0800 Subject: [PATCH 8/8] =?UTF-8?q?docs(app-iced):=20=E6=AE=8B=E4=BD=99?= =?UTF-8?q?=E6=B8=85=E5=8D=95=E6=94=B9=E6=88=90=E3=80=8C=E6=AE=8B=E7=95=99?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=EF=BC=8C=E4=B8=8D=E6=98=AF=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=E3=80=8D=EF=BC=88M285=20r3=20p2-1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r3 复审的判据改成「不假」而不是「穷尽」,按它给的结构重写这一段: - 删掉「**所有** components::selection 的 chip/switch 站点」(这个词两头都不准:字面上吞掉了 已接的 general/language.rs + general/theme.rs 那 6 枚,又把另一个页面 pages/content 的 chip 算了进来); - 第 3 条改成「还剩没搭线的那几处(已知:…;这一串会漂,接线时请更新)」,并补 java/view.rs 运行时的「设为默认」chip(action_chip 在非默认行返回可点的 Chip::filter, runtime_row 没有 forced 形参);同时明写 language/theme 那 6 枚已接、别算进残余; - 新增第 5 条:ai/view.rs 的 base_url / model / api_key 三只 Field(此前四处都不落); - 末尾加「这份清单是残留快照,不是契约:接上一处就划掉一处,判据只有『有没有拿到 forced』」。 顺带订正 r3 的两条 p3(同一段、纯注释): - 主题 chip 枚数 2 → 3(general/theme.rs 的 THEME_MODES: [ThemeMode; 3]); - 第 4 条去掉「下载按钮」:java/download_view.rs 里一个 Button 都没有(那两处是 chip,已在第 3 条)。 第 1 条(Downloads 组 + e283c585… 三态 md5)与「已经消费的」「没搭线≠组件库缺能力」两句一字未动。 纯 //! 注释,零代码行。harness.rs 347 → 356 行。 --- crates/app-iced/src/pages/harness.rs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/crates/app-iced/src/pages/harness.rs b/crates/app-iced/src/pages/harness.rs index 03e2ed0..817ab19 100644 --- a/crates/app-iced/src/pages/harness.rs +++ b/crates/app-iced/src/pages/harness.rs @@ -44,22 +44,31 @@ //! **forced 能到哪儿(M285 订正)**:`--force-state` 只改**画出来的**态,不改状态机。它经 //! `pages::Model.forced` → `settings::view(.., forced)` → `Group.body` 递到各控件。 //! -//! **已经消费的**:外观区 11 处 `choice`、语言 3 枚 / 主题 2 枚 chip、日志 2 颗 `action_button` +//! **已经消费的**:外观区 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 的两处、java 的 `prefs_view` 与 -//! `download_view`; -//! 4. java 的字段 / 预设 / 下载按钮(`java/fields.rs`、`java/prefs_view.rs`、`java/download_view.rs`)。 +//! 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 状态矩阵用的「强制视觉态」)。