diff --git a/crates/app-iced/src/pages/settings/frame.rs b/crates/app-iced/src/pages/settings/frame.rs index beb8514..9e92fa9 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,137 @@ 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 纯搬移时原样留下)——于是任何一组下面都长出同一张卡。 +/// +/// ## 两条判据怎么合起来咬死它 +/// +/// 正文由两半拼成:`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/`,也请原样保留这两条。 +/// +/// ## 抓得住 / 抓不住(据实写) +/// +/// 抓得住(三种变异都实测过):①按原样把卡加回循环之后(`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"); + + 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():分发点被改名了,本守门要跟着改"); + let rest = &FORM[start..]; + let end = rest + .find("\nfn section_columns") + .expect("settings/mod.rs 里找不到 section_columns():列生产被改名了,本守门要跟着改"); + &rest[..end] + } + + 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(` 恰好一次」等于「尾部空着」; + /// 拼装被整段重写成别的形状时计数变 0,同样红。 + #[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)` 收尾(后面紧跟函数右花括号):\ + 正文到最后一组就结束,别再往后叠东西" + ); + } + + /// 判据 ②(**生产**):真跑 `section_columns`,量它到底交出几列。 + /// + /// 这是行为断言:调的是产品路径上那个真函数,**不认任何名字**。谁往正文里多塞一件(M196 那张 + /// 「没有逐实例覆盖」的卡当初就是这么塞的),列数就不等于组数。 + /// + /// 为什么不走 `tests/scroll_fit.rs` 那条「`()` 渲染器真跑布局」的路:`form` / `section_columns` + /// 交出的是**产品渲染器**的 `Element`,要按那条路量子节点数,得把 `general` / `update` / `ai` + /// 三条线的 `body` 一并泛型化(它们不在本 mission 的 scope 内)。量列数拿到的是同一个不变量, + /// 且不需要泛型。 + #[test] + 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() + ); + } + } } diff --git a/crates/app-iced/src/pages/settings/mod.rs b/crates/app-iced/src/pages/settings/mod.rs index 4e17bf3..97680bf 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) //! @@ -106,16 +106,9 @@ //! `app/view.rs:70`),四处都要补字段 ⇒ **3 个共享文件**:`settings/state.rs`、`pages/mod.rs`、 //! **`app/view.rs`**(最后这个正是 M104 的落点,本 mission 反复设锚点要避开的就是它); //! - 给 `settings::view` **加一个 `model: &Model` 形参**(`pages/mod.rs` 的 `render` 本来就拿着 -//! `model`,直接往下传)⇒ **2 个共享文件**(`settings/mod.rs` + `pages/mod.rs`), -//! `app/view.rs` 不用动 —— **推荐这个形状**。 -//! -//! 顺带订正 r3 之前那句「在 `app/state.rs` 用真值初始化 `Groups.language_appearance`(1 个文件)」: -//! **M106 起语言已经有真值源**——`App::lang: LangState`(`crates/app-iced/src/ui/lang.rs`,用户档位 -//! `LangChoice` + 探测结论 `Detection`),由 `App::refresh_view` 经 `view::model(…, lang)` 灌进 -//! `pages::Model.lang`,设置页再从 `settings::view` 的 `language` 形参拿到它。所以下一条线要给某一组 -//! 读「当前语言」时,**不需要**再造一层状态:要么走 `Model.lang`(给 `settings::view` 加形参, -//! 与 M106 同形状),要么读 `Groups` 里本线的字段。两种形状都是「谁先撞谁付一次钱」的一次性代价, -//! 不是永久性四路撞车。 +//! `model`,直接往下传)⇒ **2 个共享文件**(`settings/mod.rs` + `pages/mod.rs`),`app/view.rs` +//! 不用动 —— **推荐这个形状**。语言也已有真值源(`ui/lang.rs` 的 `App::lang` → `view::model(…)` +//! → `Model.lang`):某组读「当前语言」走它或 `Groups` 里本线字段,别再另造一层状态。 //! 5. **首帧取数 / 异步发起**(同上,r2 复审 P1 补)—— [`Body`] 是 //! `fn(&Ui, States, Lang) -> Option`,返回不了 `Task`;而且 `settings::view` → `pages::render` //! → `App::view` **一路都返回 `Element`**(iced 0.14 的 `view` 也只返回 `Element`,`view(&self)` @@ -314,18 +307,17 @@ pub fn view<'a>( // --------------------------------------------------------------- 内容分发 -/// 表单列(正文列宽 ≤424)。`only = Some(i)` 只渲染一组(supporting-pane), -/// `None` 渲染全部(窄窗:分组导航退化成按组堆叠)。 +/// 表单列(正文列宽 ≤424)。`only = Some(i)` 只渲染一组(supporting-pane),`None` 渲染全部(窄窗按组堆叠)。 /// -/// **分发点(两条路,M106 起就并存)**: +/// **分发点(两条路,M106 起就并存)**:①`GROUP.body`——`Some(f)` 自己去 [`States`] 取数,并从形参拿 +/// **生效语言**(`None` 退回演示行);②**按下标分发**——General(语言档位)/ Update(presenter 快照)/ +/// Assistant(`Lang` + 测试结论)/ Java(`Lang` + 扫描槽)在 [`section_columns`] 里各有 `else if`,它们 +/// 的 `GROUP.body` 仍是 `None`(理由见各自的 `<组>_GROUP` 常量);分组名只作 `GROUPS` 元素的 id。 /// -/// 1. `GROUP.body`——`Some(f)` 让 `f` 自己去 [`States`] 里取它要的数据、并从形参拿**生效语言** -/// (`None` 返回值 ⇒ 退回演示行),`None` 直接画演示行([`frame::demo_card`]); -/// 2. **按下标分发**——General(要语言档位)、Update(要 presenter 快照)、Assistant(要 `Lang` -/// 与测试结论)、Java(要 `Lang` 与扫描槽)四组在下面各有 `else if`,它们的 `GROUP.body` -/// 仍是 `None`(理由见各自的 `<组>_GROUP` 常量)。 -/// -/// 除此之外这里不认识任何一组的字段:分组名只作为 `GROUPS` 元素的 id 出现。 +/// **尾部不许再挂东西(M196)**:循环之后原先无条件叠了一张「没有逐实例覆盖」的空态卡(来历见 +/// `frame.rs`),于是**每一组**下面都长出同一张卡。它**放错了地方**:真控件在**实例详情页**的「启动 +/// 参数」区(M157 `pages/instances/launch_args.rs` → `AppCommand::set_launch_args`),设置页拿不到那份 +/// 快照([`view`] 没有 `Model`);无人产出的只有 `Action::AddOverride`。删掉,守门见 `frame.rs` 末尾。 fn form<'a>( ui: &'a Ui, only: Option, @@ -333,9 +325,27 @@ 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`],只含被选中的那些;分发点见 [`form`] 的文档)。抽出来是 +/// 为了能被**行为断言**量到:`frame.rs` 的 `the_form_holds_exactly_one_column_per_group` 数它的列数。 +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; @@ -365,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, @@ -380,11 +390,10 @@ fn form<'a>( card, ] .spacing(ui.space(Space::X2)) - .width(Length::Fill), + .width(Length::Fill) + .into(), ); } - stack = stack.push(frame::overrides_empty(ui, language.effective)); - - frame::scroll(ui, stack) + columns } 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 b81c3f3..d21b601 100644 --- a/crates/i18n/src/catalog/golden/tail.rs +++ b/crates/i18n/src/catalog/golden/tail.rs @@ -321,9 +321,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", "主版本"),