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` 的名字
//!
@@ -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 那张卡就有回来的路"
);
}
}
}
}