fix(settings): 设置页尾部不再渲染不属于本页的空态卡

用户 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()` 不认任何空态件名、设置页不读实例选择。
This commit is contained in:
AskaEth committed 2026-11-08 19:13:37 +08:00
1 parent 30b61e2684
commit 7fdebba961
4 files changed
+106 -34

No files matched your search

+97 -24
View File
@@ -1,4 +1,4 @@
//! 设置页骨架:页头、左列分组导航、表单列的滚动区、演示卡片、空态位。 //! 设置页骨架:页头、左列分组导航、表单列的滚动区、演示卡片。
//! //!
//! ## 为什么这里还再导出一批 `pages::frame` 的名字 //! ## 为什么这里还再导出一批 `pages::frame` 的名字
//! //!
@@ -8,8 +8,8 @@
//! 这不是新 API,只是一条转发(新增控件仍归 [`crate::pages::frame`])。 //! 这不是新 API,只是一条转发(新增控件仍归 [`crate::pages::frame`])。
pub(super) use super::super::frame::{ pub(super) use super::super::frame::{
card, divider, empty_state, glyph_box, left_aligned, page, panes, scroll_style, single_column, card, divider, glyph_box, left_aligned, page, panes, scroll_style, single_column, text_action,
text_action, text_role, Header, ICON_PX, SCROLLBAR_MARGIN, SCROLLBAR_WIDTH, text_role, Header, ICON_PX, SCROLLBAR_MARGIN, SCROLLBAR_WIDTH,
}; };
use iced::widget::{column, container, mouse_area, row}; 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::color::Role;
use unlucky_design::component_map::Component; use unlucky_design::component_map::Component;
use unlucky_design::elevation::MaterialLayer; use unlucky_design::elevation::MaterialLayer;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale; use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space; use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole; use unlucky_design::typography::TypeRole;
@@ -30,9 +29,6 @@ use crate::theme::Ui;
use super::Group; use super::Group;
/// 空态位高度:给图标块 96 + 标题 + 副文案 + 按钮留够空间(`[估]`,按 12b §15 的元素最小高度累加)。
const EMPTY_SLOT_HEIGHT: f32 = 320.0;
/// 页头:标题 + 副标题 + 两个占位动作。 /// 页头:标题 + 副标题 + 两个占位动作。
/// ///
/// M106:三条文案走 catalog(`lang` 由 `settings::view` 传下来)。页标题复用 `NavSettings`—— /// 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() .into()
} }
/// 空态位:没有逐实例覆盖时的样子(12b §15 的口径)。 // --------------------------------------------------------------- M196 守门
pub(super) fn overrides_empty<'a>(ui: &'a Ui, lang: Lang) -> Element<'a, Message> {
let catalog = lang.catalog(); /// M196:设置页正文**不许**在最后一组之后挂一张空态卡。
container(empty_state( ///
ui, /// ## 守的是哪一件事
IconKind::Instances, ///
catalog.text(Key::SettingsOverridesEmptyTitle), /// 用户 2026-11-08 在 M174 的助手页与 M175 的 Java 页都看到同一张不属于本页的空态卡(四格图标 +
catalog.text(Key::SettingsOverridesEmptyBody), /// 「没有逐实例覆盖」+ 说明被窗口下沿截断),两张图 1180×1100、卡片位置完全一致。根因是
vec![text_action( /// `settings/mod.rs` 的 `form()` 在分组循环之后**无条件**叠了一件固定 320dp 高的空态位
ui, /// (M84 骨架期的占位,M99 纯搬移时原样留下)——于是任何一组下面都长出同一张卡。
catalog.text(Key::SettingsOverridesAdd), ///
Action::AddOverride, /// 这三条钉住的就是「尾部不许再多一件」,以及「本页不读实例选择」(于是「无选中实例」与
)], /// 「有选中实例」两种状态下本页的渲染路径逐字相同)。
)) ///
.width(Length::Fill) /// ## 为什么住在 `frame.rs` 而不是 `pages/settings/tests/`
.height(Length::Fixed(EMPTY_SLOT_HEIGHT)) ///
.into() /// `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 那张卡就有回来的路"
);
}
}
}
} }
+9 -3
View File
@@ -30,7 +30,7 @@
//! | Downloads | [`download`] | download 线 | //! | Downloads | [`download`] | download 线 |
//! | Updates | [`update`] | update 线(M159,按下标分发) | //! | Updates | [`update`] | update 线(M159,按下标分发) |
//! | Assistant | [`ai_about`](正文在 [`ai`]) | ai 线(M174,按下标分发:本组要 `Lang`) | //! | Assistant | [`ai_about`](正文在 [`ai`]) | ai 线(M174,按下标分发:本组要 `Lang`) |
//! | 页头 / 侧列 / 滚动区 / 演示卡片 / 空态位 | [`frame`] | 谁都不该改(改了就是改版面) | //! | 页头 / 侧列 / 滚动区 / 演示卡片 | [`frame`] | 谁都不该改(改了就是改版面) |
//! //!
//! ## 测试也按 section 分(M100) //! ## 测试也按 section 分(M100)
//! //!
@@ -307,6 +307,14 @@ pub fn view<'a>(
/// 表单列(正文列宽 ≤424)。`only = Some(i)` 只渲染一组(supporting-pane), /// 表单列(正文列宽 ≤424)。`only = Some(i)` 只渲染一组(supporting-pane),
/// `None` 渲染全部(窄窗:分组导航退化成按组堆叠)。 /// `None` 渲染全部(窄窗:分组导航退化成按组堆叠)。
/// ///
/// ## M196:正文到最后一组就结束,尾部**什么**都不许挂
///
/// 这里原先在循环之后无条件叠了一张「没有逐实例覆盖」的空态卡(M84 骨架期的占位,M99 纯搬移时
/// 原样留着),于是**每一组**下面都长出同一张卡:用户在 M174 的助手页与 M175 的 Java 页都看到了它
/// (两张图 1180×1100、卡片位置完全一致、说明被窗口下沿截断)。设置页永远有内容(总有一组被选中),
/// 「逐实例覆盖」既没有数据源也没有消息落点(全仓没有它的渲染点与处理器),所以它不是本页的空态:
/// **删掉,不挪给任何一组**。守门见 `frame.rs` 末尾的三条用例。
///
/// **分发点**:一组的正文由它自己文件里的 `GROUP.body` 决定——`Some(f)` 让 `f` 自己去 /// **分发点**:一组的正文由它自己文件里的 `GROUP.body` 决定——`Some(f)` 让 `f` 自己去
/// [`States`] 里取它要的数据(`None` 返回值 ⇒ 退回演示行),`None` 直接画演示行 /// [`States`] 里取它要的数据(`None` 返回值 ⇒ 退回演示行),`None` 直接画演示行
/// ([`frame::demo_card`])。这里不出现任何一个分组名,也不比较下标,更不认识任何一组的字段。 /// ([`frame::demo_card`])。这里不出现任何一个分组名,也不比较下标,更不认识任何一组的字段。
@@ -365,7 +373,5 @@ fn form<'a>(
); );
} }
stack = stack.push(frame::overrides_empty(ui, language.effective));
frame::scroll(ui, stack) frame::scroll(ui, stack)
} }
-4
View File
@@ -47,10 +47,6 @@ macro_rules! general_entries {
SettingsGroupAppearance => ("Appearance", "外观"), SettingsGroupAppearance => ("Appearance", "外观"),
SettingsGroupJava => ("Java", "Java 运行时"), SettingsGroupJava => ("Java", "Java 运行时"),
SettingsGroupDownloads => ("Downloads", "下载"), 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` 里最后一条硬编码英文) // `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 的骨架回退。"), 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 的骨架回退。"),
} } } }
-3
View File
@@ -190,9 +190,6 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
("SettingsGroupAppearance", "Appearance", "外观"), ("SettingsGroupAppearance", "Appearance", "外观"),
("SettingsGroupJava", "Java", "Java 运行时"), ("SettingsGroupJava", "Java", "Java 运行时"),
("SettingsGroupDownloads", "Downloads", "下载"), ("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 的骨架回退。"), ("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", "版本类型"), ("VersionRailTitle", "Version type", "版本类型"),
("VersionMajorTitle", "Major version", "主版本"), ("VersionMajorTitle", "Major version", "主版本"),