From 7fdebba9616c24f987ab5894cd80c890cb29ebc4 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 19:13:37 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(settings):=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E5=B0=BE=E9=83=A8=E4=B8=8D=E5=86=8D=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E4=B8=8D=E5=B1=9E=E4=BA=8E=E6=9C=AC=E9=A1=B5=E7=9A=84=E7=A9=BA?= =?UTF-8?q?=E6=80=81=E5=8D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 2026-11-08 在 M174 的助手页与 M175 的 Java 页截图里看到同一张卡(四格图标 + 「没有逐实例覆盖」+ 说明被窗口下沿截断):两张图 1180×1100、卡片位置完全一致、 独立居中在内容下方。 根因是 `pages/settings/mod.rs` 的 `form()` 在分组循环之后**无条件** push 了 `pages/settings/frame.rs` 的 `overrides_empty()`(M84 骨架期的占位,M99 纯搬移时原样 留下,固定 320dp 高):于是六个分组下面都长出同一张卡,与实例选中态无关。 修法:删掉该空态位及其唯一的三个 i18n key(`SettingsOverrides*`,金表同步删三行)。 设置页永远有内容(总有一组被选中),12b §15 的空态件是给实例/下载/日志页的; 「逐实例覆盖」全仓既无数据源也无消息落点,所以不挪给任何一组,直接不渲染。 守门(`settings/frame.rs` 的 `no_stray_empty_state` 三条,变异实测会红): 尾部不许再出现额外 push、`form()` 不认任何空态件名、设置页不读实例选择。 --- crates/app-iced/src/pages/settings/frame.rs | 121 ++++++++++++++++---- crates/app-iced/src/pages/settings/mod.rs | 12 +- crates/i18n/src/catalog/general.rs | 4 - crates/i18n/src/catalog/golden/tail.rs | 3 - 4 files changed, 106 insertions(+), 34 deletions(-) diff --git a/crates/app-iced/src/pages/settings/frame.rs b/crates/app-iced/src/pages/settings/frame.rs index beb8514..030d40d 100644 --- a/crates/app-iced/src/pages/settings/frame.rs +++ b/crates/app-iced/src/pages/settings/frame.rs @@ -1,4 +1,4 @@ -//! 设置页骨架:页头、左列分组导航、表单列的滚动区、演示卡片、空态位。 +//! 设置页骨架:页头、左列分组导航、表单列的滚动区、演示卡片。 //! //! ## 为什么这里还再导出一批 `pages::frame` 的名字 //! @@ -8,8 +8,8 @@ //! 这不是新 API,只是一条转发(新增控件仍归 [`crate::pages::frame`])。 pub(super) use super::super::frame::{ - card, divider, empty_state, glyph_box, left_aligned, page, panes, scroll_style, single_column, - text_action, text_role, Header, ICON_PX, SCROLLBAR_MARGIN, SCROLLBAR_WIDTH, + card, divider, glyph_box, left_aligned, page, panes, scroll_style, single_column, text_action, + text_role, Header, ICON_PX, SCROLLBAR_MARGIN, SCROLLBAR_WIDTH, }; use iced::widget::{column, container, mouse_area, row}; @@ -18,7 +18,6 @@ use iced::{Alignment, Background, Border, Element, Length, Padding, Theme}; use unlucky_design::color::Role; use unlucky_design::component_map::Component; use unlucky_design::elevation::MaterialLayer; -use unlucky_design::icons::IconKind; use unlucky_design::shape::ShapeScale; use unlucky_design::spacing::Space; use unlucky_design::typography::TypeRole; @@ -30,9 +29,6 @@ use crate::theme::Ui; use super::Group; -/// 空态位高度:给图标块 96 + 标题 + 副文案 + 按钮留够空间(`[估]`,按 12b §15 的元素最小高度累加)。 -const EMPTY_SLOT_HEIGHT: f32 = 320.0; - /// 页头:标题 + 副标题 + 两个占位动作。 /// /// M106:三条文案走 catalog(`lang` 由 `settings::view` 传下来)。页标题复用 `NavSettings`—— @@ -218,21 +214,98 @@ fn setting_row<'a>(ui: &'a Ui, label: &'a str, value: &'a str) -> Element<'a, Me .into() } -/// 空态位:没有逐实例覆盖时的样子(12b §15 的口径)。 -pub(super) fn overrides_empty<'a>(ui: &'a Ui, lang: Lang) -> Element<'a, Message> { - let catalog = lang.catalog(); - container(empty_state( - ui, - IconKind::Instances, - catalog.text(Key::SettingsOverridesEmptyTitle), - catalog.text(Key::SettingsOverridesEmptyBody), - vec![text_action( - ui, - catalog.text(Key::SettingsOverridesAdd), - Action::AddOverride, - )], - )) - .width(Length::Fill) - .height(Length::Fixed(EMPTY_SLOT_HEIGHT)) - .into() +// --------------------------------------------------------------- M196 守门 + +/// M196:设置页正文**不许**在最后一组之后挂一张空态卡。 +/// +/// ## 守的是哪一件事 +/// +/// 用户 2026-11-08 在 M174 的助手页与 M175 的 Java 页都看到同一张不属于本页的空态卡(四格图标 + +/// 「没有逐实例覆盖」+ 说明被窗口下沿截断),两张图 1180×1100、卡片位置完全一致。根因是 +/// `settings/mod.rs` 的 `form()` 在分组循环之后**无条件**叠了一件固定 320dp 高的空态位 +/// (M84 骨架期的占位,M99 纯搬移时原样留下)——于是任何一组下面都长出同一张卡。 +/// +/// 这三条钉住的就是「尾部不许再多一件」,以及「本页不读实例选择」(于是「无选中实例」与 +/// 「有选中实例」两种状态下本页的渲染路径逐字相同)。 +/// +/// ## 为什么住在 `frame.rs` 而不是 `pages/settings/tests/` +/// +/// `tests/` 归四条 section 线所有(各改各的),而这里守的是**本文件与 `mod.rs` 之间的分工**: +/// 壳层不提供空态位、分发点不在尾部多堆一件。判据要是哪天挪进 `tests/`,也请原样保留这三条。 +/// +/// ## 抓得住 / 抓不住(据实写) +/// +/// 抓得住:把那张卡(或**任何**尾部元素)加回 `form()` —— 变异实测:在 `frame::scroll` 之前插一句 +/// `stack = stack.push(...)`,[`the_form_stops_at_its_last_group`] 立刻红;把空态件按名字写回去, +/// [`the_form_never_renders_an_empty_state`] 红;把某件东西挂到「当前选中实例」上, +/// [`the_settings_page_never_reads_the_installation_selection`] 红。 +/// 抓不住:**另起一套名字**重画的空态(文本断言不是行为断言)。那一档只有真图能看出来 —— +/// 证据在 `DevDocs/artifacts/m196/`(Java / 助手 × 1180×760 / 1180×1100)。 +#[cfg(test)] +mod no_stray_empty_state { + /// 设置页的分发点(本文件是它的被调用方)。 + const FORM: &str = include_str!("mod.rs"); + + /// `form()` 的函数体:从签名取到文件尾(它是 `mod.rs` 最后一件)。 + fn form_body() -> &'static str { + let start = FORM + .find("fn form<'a>(") + .expect("settings/mod.rs 里找不到 form():分发点被改名了,本守门要跟着改"); + &FORM[start..] + } + + /// 判据 ①:循环之后**没有**任何额外的 `push`,且函数以滚出去收尾。 + /// + /// 循环体里只有一件(分组卡),所以「`stack = stack.push(` 恰好一次」等于「尾部空着」。 + #[test] + fn the_form_stops_at_its_last_group() { + let body = form_body(); + let pushes = body.matches("stack = stack.push(").count(); + assert_eq!( + pushes, 1, + "`form()` 里出现了 {pushes} 处 `stack = stack.push(`:分组循环里只该有一处。\ + 尾部又被叠了一件东西的话,设置页每一组下面都会多出同一件(M196 那张\ + 「没有逐实例覆盖」的卡就是这么来的)" + ); + assert!( + body.contains(" frame::scroll(ui, stack)\n}"), + "`form()` 必须以 `frame::scroll(ui, stack)` 收尾(后面紧跟函数右花括号):\ + 正文到最后一组就结束,别再往后叠东西" + ); + } + + /// 判据 ②:`form()` 不认任何空态件(名字层面)。 + /// + /// 抓得住「把那张卡原样加回来」;抓不住换名字重画的(见本模块文档)。 + #[test] + fn the_form_never_renders_an_empty_state() { + let body = form_body(); + for needle in ["empty_state", "EmptyState", "overrides"] { + assert!( + !body.contains(needle), + "`form()` 里出现了 {needle:?}:设置页永远有内容(总有一组被选中),\ + 空态件是实例 / 下载 / 日志页的东西(12b §15 的挡项就是不设空态的页),\ + 设置页不该借它来充数" + ); + } + } + + /// 判据 ③:本页**不读实例选择** —— 于是「无选中实例」与「有选中实例」两种状态下, + /// 设置页走的是同一组函数、同一拼装顺序,那一张卡在两种状态下都不可能出现。 + /// + /// 抓得住:谁把某件东西改成「按当前选中实例决定画不画」(那正是这张卡当初被误读成 + /// 「实例详情的空态」的那条路)。 + #[test] + fn the_settings_page_never_reads_the_installation_selection() { + let needles = [concat!("inst", "ance"), concat!("Inst", "ance")]; + for (name, source) in [("mod.rs", FORM), ("frame.rs", include_str!("frame.rs"))] { + for needle in needles { + assert!( + !source.contains(needle), + "{name} 里出现了 {needle:?}:设置页不读实例选择(`settings::view` 的形参里\ + 没有 `Model`)。一旦本页开始按选中实例分化,M196 那张卡就有回来的路" + ); + } + } + } } diff --git a/crates/app-iced/src/pages/settings/mod.rs b/crates/app-iced/src/pages/settings/mod.rs index 546db1b..3bc741d 100644 --- a/crates/app-iced/src/pages/settings/mod.rs +++ b/crates/app-iced/src/pages/settings/mod.rs @@ -30,7 +30,7 @@ //! | Downloads | [`download`] | download 线 | //! | Updates | [`update`] | update 线(M159,按下标分发) | //! | Assistant | [`ai_about`](正文在 [`ai`]) | ai 线(M174,按下标分发:本组要 `Lang`) | -//! | 页头 / 侧列 / 滚动区 / 演示卡片 / 空态位 | [`frame`] | 谁都不该改(改了就是改版面) | +//! | 页头 / 侧列 / 滚动区 / 演示卡片 | [`frame`] | 谁都不该改(改了就是改版面) | //! //! ## 测试也按 section 分(M100) //! @@ -307,6 +307,14 @@ pub fn view<'a>( /// 表单列(正文列宽 ≤424)。`only = Some(i)` 只渲染一组(supporting-pane), /// `None` 渲染全部(窄窗:分组导航退化成按组堆叠)。 /// +/// ## M196:正文到最后一组就结束,尾部**什么**都不许挂 +/// +/// 这里原先在循环之后无条件叠了一张「没有逐实例覆盖」的空态卡(M84 骨架期的占位,M99 纯搬移时 +/// 原样留着),于是**每一组**下面都长出同一张卡:用户在 M174 的助手页与 M175 的 Java 页都看到了它 +/// (两张图 1180×1100、卡片位置完全一致、说明被窗口下沿截断)。设置页永远有内容(总有一组被选中), +/// 「逐实例覆盖」既没有数据源也没有消息落点(全仓没有它的渲染点与处理器),所以它不是本页的空态: +/// **删掉,不挪给任何一组**。守门见 `frame.rs` 末尾的三条用例。 +/// /// **分发点**:一组的正文由它自己文件里的 `GROUP.body` 决定——`Some(f)` 让 `f` 自己去 /// [`States`] 里取它要的数据(`None` 返回值 ⇒ 退回演示行),`None` 直接画演示行 /// ([`frame::demo_card`])。这里不出现任何一个分组名,也不比较下标,更不认识任何一组的字段。 @@ -365,7 +373,5 @@ fn form<'a>( ); } - stack = stack.push(frame::overrides_empty(ui, language.effective)); - frame::scroll(ui, stack) } diff --git a/crates/i18n/src/catalog/general.rs b/crates/i18n/src/catalog/general.rs index 8724c3a..54ccfd8 100644 --- a/crates/i18n/src/catalog/general.rs +++ b/crates/i18n/src/catalog/general.rs @@ -47,10 +47,6 @@ macro_rules! general_entries { SettingsGroupAppearance => ("Appearance", "外观"), SettingsGroupJava => ("Java", "Java 运行时"), SettingsGroupDownloads => ("Downloads", "下载"), - // 逐实例覆盖的空态(M106) - SettingsOverridesEmptyTitle => ("No per-instance overrides", "没有逐实例覆盖"), - SettingsOverridesEmptyBody => ("Overrides let one instance keep its own Java runtime, memory or JVM flags while the rest of the launcher keeps the defaults above.", "逐实例覆盖让某个实例保留自己的 Java 运行时、内存或 JVM 参数,其余实例沿用上面的默认值。"), - SettingsOverridesAdd => ("Add override", "新增覆盖"), // `Route::Agent` 的回退卡正文(M106:`app/view.rs` 里最后一条硬编码英文) AgentFallbackBody => ("The assistant page is not built yet. Chat lives in the docked panel once the AI mission lands; this screen is the M68 skeleton fallback.", "助手页还没做。AI 接线落地后对话会出现在右侧停靠面板;这一屏是 M68 的骨架回退。"), } } diff --git a/crates/i18n/src/catalog/golden/tail.rs b/crates/i18n/src/catalog/golden/tail.rs index a8a89d3..4464e2b 100644 --- a/crates/i18n/src/catalog/golden/tail.rs +++ b/crates/i18n/src/catalog/golden/tail.rs @@ -190,9 +190,6 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("SettingsGroupAppearance", "Appearance", "外观"), ("SettingsGroupJava", "Java", "Java 运行时"), ("SettingsGroupDownloads", "Downloads", "下载"), - ("SettingsOverridesEmptyTitle", "No per-instance overrides", "没有逐实例覆盖"), - ("SettingsOverridesEmptyBody", "Overrides let one instance keep its own Java runtime, memory or JVM flags while the rest of the launcher keeps the defaults above.", "逐实例覆盖让某个实例保留自己的 Java 运行时、内存或 JVM 参数,其余实例沿用上面的默认值。"), - ("SettingsOverridesAdd", "Add override", "新增覆盖"), ("AgentFallbackBody", "The assistant page is not built yet. Chat lives in the docked panel once the AI mission lands; this screen is the M68 skeleton fallback.", "助手页还没做。AI 接线落地后对话会出现在右侧停靠面板;这一屏是 M68 的骨架回退。"), ("VersionRailTitle", "Version type", "版本类型"), ("VersionMajorTitle", "Major version", "主版本"), From e5a4b9bbd1e5ac5b89a4f45b5cfb385948524e3c Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 19:54:26 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(settings):=20=E6=94=B9=E5=8F=A3?= =?UTF-8?q?=E9=80=90=E5=AE=9E=E4=BE=8B=E8=A6=86=E7=9B=96=E7=9A=84=E4=BA=8B?= =?UTF-8?q?=E5=AE=9E=E9=99=88=E8=BF=B0=EF=BC=8C=E5=B9=B6=E6=8A=8A=E7=AC=AC?= =?UTF-8?q?=E4=B8=89=E6=9D=A1=E5=AE=88=E5=8D=AB=E6=8D=A2=E6=88=90=E8=A1=8C?= =?UTF-8?q?=E4=B8=BA=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 r1 的两条 P2: P2-1(`settings/mod.rs` 的 `form` 文档):原先写「『逐实例覆盖』既没有数据源也没有 消息落点」——这是错的。逐条核过:真控件在实例详情页的「启动参数」区(M157 `pages/instances/launch_args.rs`,由 `pages/instances/detail.rs` 渲染),落盘走 `app/update/instances.rs` 的 `AppCommand::set_launch_args` → presenter `host/launch_args.rs` 的 `resolved`/`apply`(写回 `config/instances/.json`), 回落与落位在 core 的 `game/launch_overrides.rs` / `launch/overrides.rs`。真正无人产出 的只有 UI 变体 `Action::AddOverride`。改口后的事实:它是**放错了地方**,不是没接。 P2-2(守卫):删掉按名字扫源码的两条(`empty_state`/`overrides` 子串、`instance` 子串)—— 前者的假警报会挡住「某组确实需要自己的空态」,后者抓的根本不是它声明的不变量(真正让实例 选择进不来的是 `settings::view` 的签名),且日后一句正当英文注释就能把它弄红。换成: - 判据 ①(保留)守**收尾**:`form()` 里 `stack = stack.push(` 恰好一处、且以 `frame::scroll(ui, stack)` 收尾(整段重写让计数变 0 也红); - 判据 ②(新增,行为断言)守**生产**:把每组一列的循环抽成 `section_columns()`, 测试真调它数返回值——窄窗 == `GROUPS.len()`,单选各组 == 1,换任何名字都躲不掉。 文档里的「抓不住」段也不再指向静态图(静态图只能证明「现在没有」)。 `form` 的拼装形状没变(循环 + `frame::scroll` 收尾),分组表、分发顺序、正文内容一字未动。 --- crates/app-iced/src/pages/settings/frame.rs | 123 +++++++++++++------- crates/app-iced/src/pages/settings/mod.rs | 50 +++++--- 2 files changed, 117 insertions(+), 56 deletions(-) diff --git a/crates/app-iced/src/pages/settings/frame.rs b/crates/app-iced/src/pages/settings/frame.rs index 030d40d..9e92fa9 100644 --- a/crates/app-iced/src/pages/settings/frame.rs +++ b/crates/app-iced/src/pages/settings/frame.rs @@ -225,38 +225,80 @@ fn setting_row<'a>(ui: &'a Ui, label: &'a str, value: &'a str) -> Element<'a, Me /// `settings/mod.rs` 的 `form()` 在分组循环之后**无条件**叠了一件固定 320dp 高的空态位 /// (M84 骨架期的占位,M99 纯搬移时原样留下)——于是任何一组下面都长出同一张卡。 /// -/// 这三条钉住的就是「尾部不许再多一件」,以及「本页不读实例选择」(于是「无选中实例」与 -/// 「有选中实例」两种状态下本页的渲染路径逐字相同)。 +/// ## 两条判据怎么合起来咬死它 +/// +/// 正文由两半拼成:`mod.rs` 的 [`section_columns`](super::section_columns) **生产**「每组一件」的列, +/// `form` 只把它们堆起来再滚出去。于是: +/// +/// - [`the_form_stops_at_its_last_group`] 守**收尾**:`form()` 里 `stack = stack.push(` 恰好一处、 +/// 且以 `frame::scroll(ui, stack)` 收尾 —— 尾部多一件、或整段换成别的拼装(push 数变 0)都红; +/// - [`the_form_holds_exactly_one_column_per_group`] 守**生产**:真调 `section_columns` 数它交出的 +/// 列数(窄窗 == `GROUPS.len()`,单选各组 == 1)—— 多塞一件就红,**不管那件叫什么名字**。 +/// +/// 两半合起来 = 「正文恰好这些列,没有尾巴」。 /// /// ## 为什么住在 `frame.rs` 而不是 `pages/settings/tests/` /// /// `tests/` 归四条 section 线所有(各改各的),而这里守的是**本文件与 `mod.rs` 之间的分工**: -/// 壳层不提供空态位、分发点不在尾部多堆一件。判据要是哪天挪进 `tests/`,也请原样保留这三条。 +/// 壳层不提供空态位、分发点不在尾部多堆一件。判据要是哪天挪进 `tests/`,也请原样保留这两条。 /// /// ## 抓得住 / 抓不住(据实写) /// -/// 抓得住:把那张卡(或**任何**尾部元素)加回 `form()` —— 变异实测:在 `frame::scroll` 之前插一句 -/// `stack = stack.push(...)`,[`the_form_stops_at_its_last_group`] 立刻红;把空态件按名字写回去, -/// [`the_form_never_renders_an_empty_state`] 红;把某件东西挂到「当前选中实例」上, -/// [`the_settings_page_never_reads_the_installation_selection`] 红。 -/// 抓不住:**另起一套名字**重画的空态(文本断言不是行为断言)。那一档只有真图能看出来 —— -/// 证据在 `DevDocs/artifacts/m196/`(Java / 助手 × 1180×760 / 1180×1100)。 +/// 抓得住(三种变异都实测过):①按原样把卡加回循环之后(`stack = stack.push(...)`)⇒ 收尾那条红; +/// ②改成「塞进列清单」(`section_columns` 里多 push 一件)⇒ 列数那条红,换任何名字都躲不掉; +/// ③把拼装整段重写成别的形状(不再用上面那个惯用法)⇒ 收尾那条数的 1 变成 0,同样红。 +/// 抓不住:**某一组自己的列里**换内容(比如某个分组真的需要自己的空态)。那不是本缺陷——本页允许 +/// 组内有空态,前提是它长在该组的列里、用本页语义的文案。`DevDocs/artifacts/m196/` 那组图是**当时** +/// 的证据,不是将来的防线(静态图只能证明「现在没有」)。 #[cfg(test)] mod no_stray_empty_state { + use unlucky_design::color::SchemeFlavor; + use unlucky_design::typography::Platform; + + use crate::pages::update::Update; + use crate::theme::{Families, Ui}; + + use super::super::{demo_groups, section_columns, Language, States, GROUPS}; + /// 设置页的分发点(本文件是它的被调用方)。 const FORM: &str = include_str!("mod.rs"); - /// `form()` 的函数体:从签名取到文件尾(它是 `mod.rs` 最后一件)。 + const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }; + + /// `form()` 的函数体:从它的签名取到下一个函数(`section_columns`)的签名之前。 + /// + /// 只取 `form` 自己:`section_columns` 里面有 `columns.push(`,那是另一条判据管的事。 fn form_body() -> &'static str { let start = FORM .find("fn form<'a>(") .expect("settings/mod.rs 里找不到 form():分发点被改名了,本守门要跟着改"); - &FORM[start..] + let rest = &FORM[start..]; + let end = rest + .find("\nfn section_columns") + .expect("settings/mod.rs 里找不到 section_columns():列生产被改名了,本守门要跟着改"); + &rest[..end] } - /// 判据 ①:循环之后**没有**任何额外的 `push`,且函数以滚出去收尾。 + fn ui() -> Ui { + Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES) + } + + /// 演示 / harness 路径那份只读状态(与 `pages::page` 同源,不伪造第二份)。 + fn states() -> States<'static> { + States { + appearance: None, + groups: demo_groups(), + } + } + + /// 判据 ①(**收尾**):循环之后没有任何额外的 `push`,且函数以滚出去收尾。 /// - /// 循环体里只有一件(分组卡),所以「`stack = stack.push(` 恰好一次」等于「尾部空着」。 + /// 循环体里只有一件(一组一列),所以「`stack = stack.push(` 恰好一次」等于「尾部空着」; + /// 拼装被整段重写成别的形状时计数变 0,同样红。 #[test] fn the_form_stops_at_its_last_group() { let body = form_body(); @@ -274,38 +316,35 @@ mod no_stray_empty_state { ); } - /// 判据 ②:`form()` 不认任何空态件(名字层面)。 + /// 判据 ②(**生产**):真跑 `section_columns`,量它到底交出几列。 /// - /// 抓得住「把那张卡原样加回来」;抓不住换名字重画的(见本模块文档)。 + /// 这是行为断言:调的是产品路径上那个真函数,**不认任何名字**。谁往正文里多塞一件(M196 那张 + /// 「没有逐实例覆盖」的卡当初就是这么塞的),列数就不等于组数。 + /// + /// 为什么不走 `tests/scroll_fit.rs` 那条「`()` 渲染器真跑布局」的路:`form` / `section_columns` + /// 交出的是**产品渲染器**的 `Element`,要按那条路量子节点数,得把 `general` / `update` / `ai` + /// 三条线的 `body` 一并泛型化(它们不在本 mission 的 scope 内)。量列数拿到的是同一个不变量, + /// 且不需要泛型。 #[test] - fn the_form_never_renders_an_empty_state() { - let body = form_body(); - for needle in ["empty_state", "EmptyState", "overrides"] { - assert!( - !body.contains(needle), - "`form()` 里出现了 {needle:?}:设置页永远有内容(总有一组被选中),\ - 空态件是实例 / 下载 / 日志页的东西(12b §15 的挡项就是不设空态的页),\ - 设置页不该借它来充数" + fn the_form_holds_exactly_one_column_per_group() { + let ui = ui(); + let update = Update::default(); + + let narrow = section_columns(&ui, None, states(), Language::default(), &update); + assert_eq!( + narrow.len(), + GROUPS.len(), + "窄窗(按组堆叠)的正文应当是「每组一件」:多出来的那一件就是 M196 那张卡的位置" + ); + + for index in 0..GROUPS.len() { + let single = section_columns(&ui, Some(index), states(), Language::default(), &update); + assert_eq!( + single.len(), + 1, + "supporting-pane 只该画被选中的那一组(下标 {index}),实际拿到 {} 列", + single.len() ); } } - - /// 判据 ③:本页**不读实例选择** —— 于是「无选中实例」与「有选中实例」两种状态下, - /// 设置页走的是同一组函数、同一拼装顺序,那一张卡在两种状态下都不可能出现。 - /// - /// 抓得住:谁把某件东西改成「按当前选中实例决定画不画」(那正是这张卡当初被误读成 - /// 「实例详情的空态」的那条路)。 - #[test] - fn the_settings_page_never_reads_the_installation_selection() { - let needles = [concat!("inst", "ance"), concat!("Inst", "ance")]; - for (name, source) in [("mod.rs", FORM), ("frame.rs", include_str!("frame.rs"))] { - for needle in needles { - assert!( - !source.contains(needle), - "{name} 里出现了 {needle:?}:设置页不读实例选择(`settings::view` 的形参里\ - 没有 `Model`)。一旦本页开始按选中实例分化,M196 那张卡就有回来的路" - ); - } - } - } } diff --git a/crates/app-iced/src/pages/settings/mod.rs b/crates/app-iced/src/pages/settings/mod.rs index 3bc741d..17d57a4 100644 --- a/crates/app-iced/src/pages/settings/mod.rs +++ b/crates/app-iced/src/pages/settings/mod.rs @@ -304,20 +304,19 @@ pub fn view<'a>( // --------------------------------------------------------------- 内容分发 -/// 表单列(正文列宽 ≤424)。`only = Some(i)` 只渲染一组(supporting-pane), -/// `None` 渲染全部(窄窗:分组导航退化成按组堆叠)。 +/// 表单列(正文列宽 ≤424)。`only = Some(i)` 只渲染一组(supporting-pane),`None` 渲染全部(窄窗按组堆叠)。 /// /// ## M196:正文到最后一组就结束,尾部**什么**都不许挂 /// -/// 这里原先在循环之后无条件叠了一张「没有逐实例覆盖」的空态卡(M84 骨架期的占位,M99 纯搬移时 -/// 原样留着),于是**每一组**下面都长出同一张卡:用户在 M174 的助手页与 M175 的 Java 页都看到了它 -/// (两张图 1180×1100、卡片位置完全一致、说明被窗口下沿截断)。设置页永远有内容(总有一组被选中), -/// 「逐实例覆盖」既没有数据源也没有消息落点(全仓没有它的渲染点与处理器),所以它不是本页的空态: -/// **删掉,不挪给任何一组**。守门见 `frame.rs` 末尾的三条用例。 +/// 这里原先在循环之后无条件叠了一张「没有逐实例覆盖」的空态卡(M84 骨架期的占位,M99 纯搬移时原样 +/// 留着),于是**每一组**下面都长出同一张卡(用户在 M174 的助手页与 M175 的 Java 页都看到了它:两张图 +/// 1180×1100、位置一致、说明被窗口下沿截断)。**删掉,不挪给任何一组。** /// -/// **分发点**:一组的正文由它自己文件里的 `GROUP.body` 决定——`Some(f)` 让 `f` 自己去 -/// [`States`] 里取它要的数据(`None` 返回值 ⇒ 退回演示行),`None` 直接画演示行 -/// ([`frame::demo_card`])。这里不出现任何一个分组名,也不比较下标,更不认识任何一组的字段。 +/// 它不是「还没接的活」,是**放错了地方**:逐实例覆盖(内存 / Java 运行时 / JVM 参数)的真控件在 +/// **实例详情页**的「启动参数」区(M157 `pages/instances/launch_args.rs` → `AppCommand::set_launch_args` +/// → presenter `host/launch_args.rs` 的 `resolved`/`apply`,写回 `config/instances/.json`;回落与 +/// 落位在 core 的 `game/launch_overrides.rs` / `launch/overrides.rs`)。设置页拿不到那份快照,也不该 +/// 重复一份([`view`] 的形参里没有 `Model`);真正无人产出的是 UI 变体 `Action::AddOverride`(死消息)。 fn form<'a>( ui: &'a Ui, only: Option, @@ -325,9 +324,31 @@ fn form<'a>( language: Language, update: &'a Update, ) -> Element<'a, Message> { - let catalog = language.effective.catalog(); let mut stack = column![].spacing(ui.space(Space::X6)).width(Length::Fill); + for section in section_columns(ui, only, states, language, update) { + stack = stack.push(section); + } + + frame::scroll(ui, stack) +} + +/// 正文里**每组一件**的列(该组标题 + 正文),顺序 = [`GROUPS`] 顺序,只含被选中的那些。 +/// +/// **分发点**:正文由 `GROUP.body` 决定——`Some(f)` 自己去 [`States`] 取数(`None` 退回演示行); +/// 这里不出现任何分组名、不比较下标、也不认识任何一组的字段。抽成独立函数是为了能被**行为断言** +/// 量到(`frame.rs` 的 `the_form_holds_exactly_one_column_per_group` 数它的列数,换名字也红); +/// 收尾留在 [`form`],由 push 计数守住——两半合起来 =「正文恰好这些列,没有尾巴」。 +fn section_columns<'a>( + ui: &'a Ui, + only: Option, + states: States<'a>, + language: Language, + update: &'a Update, +) -> Vec> { + let catalog = language.effective.catalog(); + let mut columns = Vec::new(); + for (index, group) in GROUPS.iter().enumerate() { if only.is_some_and(|selected| selected != index) { continue; @@ -354,7 +375,7 @@ fn form<'a>( .unwrap_or_else(|| frame::demo_card(ui, group.rows)) }; - stack = stack.push( + columns.push( column![ frame::text_role( ui, @@ -369,9 +390,10 @@ fn form<'a>( card, ] .spacing(ui.space(Space::X2)) - .width(Length::Fill), + .width(Length::Fill) + .into(), ); } - frame::scroll(ui, stack) + columns }