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", "主版本"),