Merge branch 'feat/m196-straycard-settings-page-renders-for'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-09 05:23:49 +08:00
commit 98262d3835
4 files changed
+172 -58

No files matched your search

+136 -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,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()
);
}
}
}
+36 -27
View File
@@ -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<Element>`,返回不了 `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<usize>,
@@ -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<usize>,
states: States<'a>,
language: Language,
update: &'a Update,
) -> Vec<Element<'a, Message>> {
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
}
-4
View File
@@ -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 的骨架回退。"),
} }
-3
View File
@@ -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", "主版本"),