feat(settings): M299 选中项收口到 selected_fill + 分组导航改真按钮 + tabs 用 status

- pages/settings/frame.rs:分组导航从 mouse_area 换成 button(四态可辨),
  几何不变(ShapeScale::Full 胶囊),颜色走唯一的 selected_fill;forced 从
  settings::view 递下来,于是 --force-state 能拍到这一处
- language / theme / appearance choice 三处「选中档位」补状态层
- components/selection/paint/chip.rs:选中分支用上 state 参数
- shell/nav.rs / shell/nav/account.rs:两处私有拷贝收口到 selected_fill(几何没动)
- components/navigation/tabs.rs:状态层在样式闭包里跟 button::Status 求值
- 新增门禁 selection/tests/single_entry.rs:11 个站点必须调 selected_fill +
  产品代码里不许再出现 or_else(|| Some(ui.color( 这个私有拷贝形态
- 行数:frame.rs / nav.rs / appearance::section 的单测按「纯搬移」拆出去
This commit is contained in:
AskaEth committed 2026-11-13 14:41:57 +08:00
1 parent 9b6e92c7b4
commit 0d917e88da
16 files changed
+1072 -355

No files matched your search

@@ -297,16 +297,19 @@ fn choice_style<'a>(
forced: Option<Interaction>,
) -> impl Fn(&IcedTheme, button::Status) -> button::Style + 'a {
move |_theme, status| {
let state = crate::pages::settings::resolve_state(forced, status);
// 选中档位的底**也是**可交互的(M299):走唯一入口 `selected_fill`——静息逐位等于
// `SecondaryContainer`,hover / pressed 在它上面再叠一层状态层。
let background = if selected {
Some(Background::Color(ui.color(Role::SecondaryContainer)))
} else {
interaction::layer_color(
Some(Background::Color(interaction::selected_fill(
ui,
crate::pages::settings::resolve_state(forced, status),
Role::OnSurface,
frame::BASE.role(),
)
.map(Background::Color)
state,
Role::OnSecondaryContainer,
Role::SecondaryContainer,
)))
} else {
interaction::layer_color(ui, state, Role::OnSurface, frame::BASE.role())
.map(Background::Color)
};
button::Style {
@@ -323,68 +326,4 @@ fn choice_style<'a>(
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_design::color::{SchemeFlavor, StateLayer};
use unlucky_design::typography::Platform;
use crate::theme::Families;
fn ui() -> Ui {
Ui::with_platform(
SchemeFlavor::Light,
Platform::Windows,
Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
},
)
}
fn solid(style: button::Style) -> Color {
match style.background {
Some(Background::Color(color)) => color,
None => panic!("未选中态的 hover/pressed 必须叠状态层"),
Some(other) => panic!("背景必须是实色,得到 {other:?}"),
}
}
/// hover(8%) 与 pressed(10%) 是**两档**,且各自逐值等于令牌表(把 10% 改成 11% 也会红)。
///
/// 抓得住:①回到 `matches!(status, Hovered | Pressed)` 合并分支(两色相等 ⇒ 第一条红);
/// ②pressed 用 hover 的 α、或合成底被换回画布(第二、三条红)。
/// 抓不住:观感;**过渡中间帧**(forced 路径不套 `tracked`,`layer_color` 直落端点值)。
///
/// 底的角色取 `MaterialLayer::Card.surface_role()`(`frame::BASE` 就是它):既盯住「叠的是卡底」
/// 这条语义,也不往 `settings/*.rs` 的接线点计数里多塞一次字面量(`pages/tests/base_wiring.rs`)。
#[test]
fn the_choice_button_draws_pressed_darker_than_hover() {
let ui = ui();
let style = choice_style(
&ui,
false,
ui.color(Role::OnSurfaceVariant),
ui.radius(ShapeScale::Full, ui.metrics().control_height),
None,
);
let theme = ui.theme();
let hover = solid(style(&theme, button::Status::Hovered));
let pressed = solid(style(&theme, button::Status::Pressed));
assert_ne!(hover, pressed, "hover 与 pressed 必须是两档(8% / 10%)");
let base = MaterialLayer::Card.surface_role();
assert_eq!(
hover,
ui.state_over(StateLayer::Hover, Role::OnSurface, base)
);
assert_eq!(
pressed,
ui.state_over(StateLayer::Pressed, Role::OnSurface, base)
);
assert!(
style(&theme, button::Status::Active).background.is_none(),
"静息态不叠状态层"
);
}
}
mod tests;
@@ -0,0 +1,118 @@
//! `appearance::section` 的单测(M299 从 `mod.rs` 纯搬移,守 400 行硬线)。
//!
//! 判据与注释一字未动,只是**换文件**:`mod.rs` 加了一条「选中的档位跟态走」之后到 438 行。
//! 模块路径仍是 `appearance::section::tests`(`mod.rs` 里改成 `mod tests;`),`super::*` 拿到的
//! 还是 `appearance::section`。
use super::*;
use unlucky_design::color::{SchemeFlavor, StateLayer};
use unlucky_design::typography::Platform;
use crate::theme::Families;
fn ui() -> Ui {
Ui::with_platform(
SchemeFlavor::Light,
Platform::Windows,
Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
},
)
}
fn solid(style: button::Style) -> Color {
match style.background {
Some(Background::Color(color)) => color,
None => panic!("未选中态的 hover/pressed 必须叠状态层"),
Some(other) => panic!("背景必须是实色,得到 {other:?}"),
}
}
/// hover(8%) 与 pressed(10%) 是**两档**,且各自逐值等于令牌表(把 10% 改成 11% 也会红)。
///
/// 抓得住:①回到 `matches!(status, Hovered | Pressed)` 合并分支(两色相等 ⇒ 第一条红);
/// ②pressed 用 hover 的 α、或合成底被换回画布(第二、三条红)。
/// 抓不住:观感;**过渡中间帧**(forced 路径不套 `tracked`,`layer_color` 直落端点值)。
///
/// 底的角色取 `MaterialLayer::Card.surface_role()`(`frame::BASE` 就是它):既盯住「叠的是卡底」
/// 这条语义,也不往 `settings/*.rs` 的接线点计数里多塞一次字面量(`pages/tests/base_wiring.rs`)。
#[test]
fn the_choice_button_draws_pressed_darker_than_hover() {
let ui = ui();
let style = choice_style(
&ui,
false,
ui.color(Role::OnSurfaceVariant),
ui.radius(ShapeScale::Full, ui.metrics().control_height),
None,
);
let theme = ui.theme();
let hover = solid(style(&theme, button::Status::Hovered));
let pressed = solid(style(&theme, button::Status::Pressed));
assert_ne!(hover, pressed, "hover 与 pressed 必须是两档(8% / 10%)");
let base = MaterialLayer::Card.surface_role();
assert_eq!(
hover,
ui.state_over(StateLayer::Hover, Role::OnSurface, base)
);
assert_eq!(
pressed,
ui.state_over(StateLayer::Pressed, Role::OnSurface, base)
);
assert!(
style(&theme, button::Status::Active).background.is_none(),
"静息态不叠状态层"
);
}
/// **选中的档位也跟态走**(M299):底是实色 `SecondaryContainer`,状态层叠在它上面。
///
/// 抓得住:①把选中支改回 `Some(ui.color(SecondaryContainer))`(= 选中就不响应)⇒ hover /
/// pressed 都等于静息,前三条红;②hover 与 pressed 同 α ⇒ 第四条红;③合成底被换掉 ⇒ 后两条
/// 逐值断言红。
/// 抓不住:像素(`DevDocs/artifacts/m299/settings-appearance-*-hover|pressed.png`,`--force-state`
/// 经 `settings::view` → `appearance::section` 的 `forced`);也抓不住「自己抄了一份**正确**的
/// 叠层」——由 `selection/tests/single_entry.rs` 的接线扫描负责。
#[test]
fn a_selected_choice_reacts_to_hover_and_press() {
let ui = ui();
let style = choice_style(
&ui,
true,
ui.color(Role::OnSecondaryContainer),
ui.radius(ShapeScale::Full, ui.metrics().control_height),
None,
);
let theme = ui.theme();
let rest = solid(style(&theme, button::Status::Active));
let hover = solid(style(&theme, button::Status::Hovered));
let pressed = solid(style(&theme, button::Status::Pressed));
assert_eq!(
rest,
ui.color(Role::SecondaryContainer),
"选中静息 = 实色容器"
);
assert_ne!(hover, rest, "选中 + hover 必须与静息不同");
assert_ne!(pressed, rest, "选中 + pressed 必须与静息不同");
assert_ne!(hover, pressed, "8% 与 10% 必须能区分");
assert_eq!(
hover,
ui.state_over(
StateLayer::Hover,
Role::OnSecondaryContainer,
Role::SecondaryContainer
)
);
assert_eq!(
pressed,
ui.state_over(
StateLayer::Pressed,
Role::OnSecondaryContainer,
Role::SecondaryContainer
)
);
}
+160 -175
View File
@@ -12,8 +12,8 @@ pub(super) use super::super::frame::{
text_role, Header, ICON_PX, SCROLLBAR_MARGIN, SCROLLBAR_WIDTH,
};
use iced::widget::{column, container, mouse_area, row};
use iced::{Alignment, Background, Border, Element, Length, Padding, Theme};
use iced::widget::{button, column, container, row};
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
@@ -23,6 +23,7 @@ use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Key, Lang};
use crate::app::components::interaction::{self, Interaction};
use crate::pages::appearance::Change;
use crate::pages::{Action, Message};
use crate::theme::Ui;
@@ -52,7 +53,15 @@ pub(super) fn header<'a>(ui: &'a Ui, lang: Lang) -> Header<'a> {
// --------------------------------------------------------------- 侧列
/// 左列分组导航(supporting-pane 的辅助列,宽 = 令牌 `nav_drawer_width` = 240)。
pub(super) fn group_nav<'a>(ui: &'a Ui, selected: usize, lang: Lang) -> Element<'a, Message> {
///
/// `forced`(M299)是 `--force-state` 的落点,口径与 [`super::resolve_state`] 相同:`Some` 时整列
/// 按那一态画(取证),`None` 时按观测到的 `button::Status` 画(产品路径的常态)。
pub(super) fn group_nav<'a>(
ui: &'a Ui,
selected: usize,
lang: Lang,
forced: Option<Interaction>,
) -> Element<'a, Message> {
let catalog = lang.catalog();
let items = column(
super::GROUPS
@@ -62,7 +71,7 @@ pub(super) fn group_nav<'a>(ui: &'a Ui, selected: usize, lang: Lang) -> Element<
let label = super::GROUP_LABELS
.get(index)
.map_or(group.name, |key| catalog.text(*key));
group_item(ui, group, label, index, index == selected)
group_item(ui, group, label, index, index == selected, forced)
})
.collect::<Vec<_>>(),
)
@@ -84,15 +93,37 @@ pub(super) fn group_nav<'a>(ui: &'a Ui, selected: usize, lang: Lang) -> Element<
.into()
}
/// 一枚分组项:**真按钮** + 胶囊底(M299)。
///
/// ## 为什么从 `mouse_area` 换成 `button`
///
/// 用户原话:「侧边栏按钮 hover 和 click 状态是父容器整个方形区域颜色加深,而不是 MD3E 应有的
/// 胶囊 UI」。此前这里是 `mouse_area` 包一个静态实色容器:`mouse_area` 根本不出
/// `button::Status`,于是选中的那一组**一个像素都不变**(没有 hover、没有 pressed),未选中的
/// 那一组也毫无反馈——「加深」的只剩别处(外壳导航轨)的方形命中区。
///
/// ## 几何没动(M283 定稿的那套形状语汇)
///
/// 高 = `LM.row_height`、圆角 = [`ShapeScale::Full`](实量半径 = 高/2,就是胶囊),宽 = 列宽,
/// 内边距 `Space::X3`。本 mission 只换**颜色入口**:选中支走 [`interaction::selected_fill`],
/// 未选中支走 [`interaction::layer_color`](叠在侧列自己的卡底 `frame::BASE` 上)。
///
/// ## 内容色为什么还是静态的
///
/// 图标与文案由 `glyph_box` / `text_role` 各自**显式着色**(不继承 `button::Style::text_color`),
/// 所以 hover / pressed 只改底不改墨色。MD3 里未选中项 hover 时文字提到 `OnSurface`——这一条
/// **没有实现**(要重建整棵内容树才做得到),已作为 finding 报给塔,别把它当已覆盖。
fn group_item<'a>(
ui: &'a Ui,
group: &'a Group,
label: &'a str,
index: usize,
selected: bool,
forced: Option<Interaction>,
) -> Element<'a, Message> {
let spec = Component::NavigationItem.spec();
let height = ui.metrics().row_height;
let radius = ui.radius(ShapeScale::Full, height);
let content_color = if selected {
ui.color(Role::OnSecondaryContainer)
@@ -112,22 +143,50 @@ fn group_item<'a>(
.width(Length::Fill)
.height(height)
.padding(Padding::from([0.0, ui.space(Space::X3)]))
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
background: selected.then(|| Background::Color(ui.color(Role::SecondaryContainer))),
border: Border {
radius: ui.radius(ShapeScale::Full, height),
..Border::default()
},
..container::Style::default()
});
.align_y(Alignment::Center);
// 点选切分组:走页面自己的 `Message::Appearance(SelectGroup)`(只改视图状态,不落盘)。
mouse_area(pill)
button(pill)
.width(Length::Fill)
.height(height)
.padding(0)
.style(move |_theme, status| button::Style {
background: group_fill(ui, selected, forced, status).map(Background::Color),
text_color: content_color,
border: Border {
radius,
..Border::default()
},
shadow: Shadow::default(),
snap: true,
})
.on_press(Message::Appearance(Change::SelectGroup(index)))
.into()
}
/// 分组项的底:选中支叠在实色 `SecondaryContainer` 上,未选中支只有状态层(M299)。
///
/// 两条分支不许合并(与 `shell/nav.rs` 的药丸、`drawer/item.rs` 的 `pill_color` 同一条口径):
/// 选中项的静息**有底**,未选中的静息**没有底**。
fn group_fill(
ui: &Ui,
selected: bool,
forced: Option<Interaction>,
status: button::Status,
) -> Option<Color> {
let state = super::resolve_state(forced, status);
if selected {
Some(interaction::selected_fill(
ui,
state,
Role::OnSecondaryContainer,
Role::SecondaryContainer,
))
} else {
interaction::layer_color(ui, state, Role::OnSurface, super::super::frame::BASE.role())
}
}
// --------------------------------------------------------------- 表单列
/// 表单列的滚动区:只有正文滚,页头不动(与实例页列表列同一套口径)。
@@ -216,179 +275,105 @@ fn setting_row<'a>(ui: &'a Ui, label: &'a str, value: &'a str) -> Element<'a, Me
// --------------------------------------------------------------- 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_stacks_nothing_but_its_sections`] 守**收尾**:正文的唯一来源是 `section_columns`
/// (`push(` 恰好一次、实参必须是循环变量、尾表达式是 `frame::scroll`)—— 多一件就红;
/// - [`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).push(frame::demo_card(…))`)⇒ 收尾那条数到 2 处 `push(`,红
/// (与 `cargo fmt` 是否拆行无关);②换成别的来源(`stack = stack.push(frame::text_role(…))`)⇒
/// 实参不是 `section`,红;③塞进列清单(`section_columns` 里多 push 一件)⇒ 列数那条红;
/// ④整段改写成 `extend` / `chain` / 尾表达式再包一层 ⇒ 收尾那条红。
/// 抓不住:**某一组自己的列里**换内容(比如某个分组真的需要自己的空态)。那不是本缺陷——本页允许
/// 组内有空态,前提是它长在该组的列里、用本页语义的文案。`DevDocs/artifacts/m196/` 那组图是**当时**
/// 的证据,不是将来的防线(静态图只能证明「现在没有」)。
// M299:守门本体搬进 `frame/no_stray_empty_state.rs`。只为行数——分组导航改成真按钮之后
// 本文件会超过 400 行硬线(AGENTS.md §3-1)。判据一条没改,只是 `include_str!` 的深度变了。
#[cfg(test)]
mod no_stray_empty_state {
use unlucky_design::color::SchemeFlavor;
mod no_stray_empty_state;
/// 分组项的底色(M299):选中支四条断言 + 未选中支的对照。
///
/// 抓得住:①把选中支改回「选中就不叠层」(`selected.then(|| ui.color(SecondaryContainer))`)⇒
/// hover / pressed 都等于静息,前三条红;②把 hover 的 α 抄成 pressed 的 10% ⇒ `hover != pressed`
/// 红;③未选中支的合成底换成画布(侧列画的是 `MaterialLayer::Card`)⇒ 最后一条红。
/// 抓不住:像素(`DevDocs/artifacts/m299/settings-frame-*-hover|pressed.png`,`--force-state`
/// 经 `settings::view` 递到 `group_nav` 的 `forced`);也抓不住「自己抄了一份**正确**的叠层」——
/// 那是 `selection/tests/single_entry.rs` 的接线扫描管的事。
#[cfg(test)]
mod group_fill_tests {
use super::*;
use unlucky_design::color::{SchemeFlavor, StateLayer};
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]
}
use crate::theme::Families;
fn ui() -> Ui {
Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES)
Ui::with_platform(
SchemeFlavor::Light,
Platform::Windows,
Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
},
)
}
/// `form()` 的函数体,**剥掉每行 `//` 之后的部分**:判据数的是代码,注释里的 `push(` 不算。
fn form_code() -> String {
form_body()
.lines()
.map(|line| line.split("//").next().unwrap_or_default())
.collect::<Vec<_>>()
.join("\n")
}
/// 演示 / harness 路径那份只读状态(与 `pages::page` 同源,不伪造第二份)。
fn states() -> States<'static> {
States {
appearance: None,
groups: demo_groups(),
settings: &[],
}
}
/// 判据 ①(**收尾**):正文的柱子只有一个来源——`section_columns` 交出的那一列。
///
/// M195 r2 复审实测的盲区:原来数的是 `stack = stack.push(` 的出现**次数**,于是
/// `stack = stack.push(section).push(frame::demo_card(ui, &[]));` 一句折叠里塞第二件时计数仍是 1
/// (第二个 `.push(` 前面没有 `stack = `)⇒ 判据 ① 与 ② 同时绿。那是在数**写法**。现在按**角色**
/// 断言:循环头直接迭代 `section_columns`、`push(` 恰好一次且实参是那个循环变量、尾表达式恰好是
/// `frame::scroll(ui, stack)`、没有 `extend`/`chain`/`once`/`concat`/`append`。`push(` 按**次数**
/// 数(拆行不影响),注释里的不算(先剥掉每行 `//`)。变异证明见模块文档的「抓得住」。
#[test]
fn the_form_stacks_nothing_but_its_sections() {
let body = form_code();
assert!(
body.contains("for section in section_columns("),
"`form()` 的循环头必须直接迭代 `section_columns(...)`:正文节点的唯一来源就是它"
);
let pushes = body.matches("push(").count();
assert_eq!(
pushes, 1,
"`form()` 里出现了 {pushes} 处 `push(`:分组循环里只该有一处。多出来的那一处就是\
「每组下面多一件」(M196 那张卡)——同一句里折叠成 `.push(a).push(b)` 也算两处"
);
let at = body.find("push(").expect("上面刚数过一次") + "push(".len();
let argument = body[at..].split(')').next().unwrap_or_default().trim();
assert_eq!(
argument, "section",
"被压进正文的必须是 `section_columns` 交出的那一列(循环变量 `section`),实际是 \
`{argument}`:别的来源(演示卡 / 空态 / 任何控件)都不许进正文"
);
for append in ["extend(", "chain(", "once(", "concat(", "append("] {
assert!(
!body.contains(append),
"`form()` 用了 `{append}`:正文只许从 `section_columns` 逐列 push 进来"
);
}
let tail: Vec<&str> = body
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect();
assert_eq!(
tail.iter().rev().take(2).copied().collect::<Vec<_>>(),
vec!["}", "frame::scroll(ui, stack)"],
"`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() {
fn a_selected_group_reacts_to_hover_and_press() {
let ui = ui();
let update = Update::default();
let language = Language::default();
let fill = |status| group_fill(&ui, true, None, status).expect("选中项静息也有底");
let rest = fill(button::Status::Active);
let hover = fill(button::Status::Hovered);
let pressed = fill(button::Status::Pressed);
let narrow = section_columns(&ui, None, states(), language, &update, None);
assert_eq!(
narrow.len(),
GROUPS.len(),
"窄窗(按组堆叠)的正文应当是「每组一件」:多出来的那一件就是 M196 那张卡的位置"
rest,
ui.color(Role::SecondaryContainer),
"选中静息 = 实色容器"
);
assert_ne!(hover, rest, "选中 + hover 必须与静息不同");
assert_ne!(pressed, rest, "选中 + pressed 必须与静息不同");
assert_ne!(hover, pressed, "8% 与 10% 必须能区分");
assert_eq!(
pressed,
ui.state_over(
StateLayer::Pressed,
Role::OnSecondaryContainer,
Role::SecondaryContainer
)
);
for index in 0..GROUPS.len() {
let single = section_columns(&ui, Some(index), states(), language, &update, None);
assert_eq!(
single.len(),
1,
"supporting-pane 只该画被选中的那一组(下标 {index}),实际拿到 {} 列",
single.len()
);
}
// 未选中:静息**没有底**(那是状态层自己的事),hover 叠在侧列的卡底上
assert_eq!(group_fill(&ui, false, None, button::Status::Active), None);
assert_eq!(
group_fill(&ui, false, None, button::Status::Hovered),
Some(ui.state_over(
StateLayer::Hover,
Role::OnSurface,
MaterialLayer::Card.surface_role()
)),
"未选中项的 hover 必须叠在卡底(不是画布)上"
);
}
/// `--force-state` 经 `forced` 覆盖观测 status(与 `settings::resolve_state` 同一条口径)。
///
/// 抓得住:`group_fill` 忘了把 `forced` 递给 `resolve_state`(那三个像素图的判据就假了)。
/// 抓不住:像素通道到底有没有把 `forced` 传到(那是 `pages/settings/mod.rs` 的接线 + 图)。
#[test]
fn the_forced_state_wins_over_the_observed_status() {
let ui = ui();
let hover = group_fill(&ui, true, None, button::Status::Hovered);
assert_eq!(
group_fill(
&ui,
true,
Some(Interaction::Hovered),
button::Status::Active
),
hover,
"forced 必须覆盖观测到的 Active"
);
assert_eq!(
group_fill(
&ui,
true,
Some(Interaction::Enabled),
button::Status::Pressed
),
group_fill(&ui, true, None, button::Status::Active),
"forced=静息时必须把 pressed 压回静息(取证图要能拍「无状态层」)"
);
}
}
@@ -0,0 +1,178 @@
//! M196 守门:设置页正文**不许**在最后一组之后挂一张空态卡。
//!
//! M299:从 `frame.rs` 原样搬出来,只为行数(那个文件加了分组导航的状态层后撞 400 行硬线)。
//! 判据与文案一字未动,只改了 ①`include_str!("mod.rs")` → `"../mod.rs"`(相对深度变了)
//! ②正文里的 `section_columns` 链接改成全路径(`super::super` 的相对位置也跟着变了)。
//!
//! ## 守的是哪一件事
//!
//! 用户 2026-11-08 在 M174 的助手页与 M175 的 Java 页都看到同一张不属于本页的空态卡(四格图标 +
//! 「没有逐实例覆盖」+ 说明被窗口下沿截断),两张图 1180×1100、卡片位置完全一致。根因是
//! `settings/mod.rs` 的 `form()` 在分组循环之后**无条件**叠了一件固定 320dp 高的空态位
//! (M84 骨架期的占位,M99 纯搬移时原样留下)——于是任何一组下面都长出同一张卡。
//!
//! ## 两条判据怎么合起来咬死它
//!
//! 正文由两半拼成:`mod.rs` 的 [`section_columns`](crate::pages::settings::section_columns)
//! **生产**「每组一件」的列,`form` 只把它们堆起来再滚出去。于是:
//!
//! - [`the_form_stacks_nothing_but_its_sections`] 守**收尾**:正文的唯一来源是 `section_columns`
//! (`push(` 恰好一次、实参必须是循环变量、尾表达式是 `frame::scroll`)—— 多一件就红;
//! - [`the_form_holds_exactly_one_column_per_group`] 守**生产**:真调 `section_columns` 数它交出的
//! 列数(窄窗 == `GROUPS.len()`,单选各组 == 1)—— 多塞一件就红,**不管那件叫什么名字**。
//!
//! 两半合起来 = 「正文恰好这些列,没有尾巴」。
//!
//! ## 为什么住在设置页壳层而不是 `pages/settings/tests/`
//!
//! `tests/` 归四条 section 线所有(各改各的),而这里守的是**壳层与 `mod.rs` 之间的分工**:
//! 壳层不提供空态位、分发点不在尾部多堆一件。判据要是哪天挪进 `tests/`,也请原样保留这两条。
//!
//! ## 抓得住 / 抓不住(据实写)
//!
//! 抓得住(四种变异都实测过):①把卡折叠回循环
//! (`stack = stack.push(section).push(frame::demo_card(…))`)⇒ 收尾那条数到 2 处 `push(`,红
//! (与 `cargo fmt` 是否拆行无关);②换成别的来源(`stack = stack.push(frame::text_role(…))`)⇒
//! 实参不是 `section`,红;③塞进列清单(`section_columns` 里多 push 一件)⇒ 列数那条红;
//! ④整段改写成 `extend` / `chain` / 尾表达式再包一层 ⇒ 收尾那条红。
//! 抓不住:**某一组自己的列里**换内容(比如某个分组真的需要自己的空态)。那不是本缺陷——本页允许
//! 组内有空态,前提是它长在该组的列里、用本页语义的文案。`DevDocs/artifacts/m196/` 那组图是**当时**
//! 的证据,不是将来的防线(静态图只能证明「现在没有」)。
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)
}
/// `form()` 的函数体,**剥掉每行 `//` 之后的部分**:判据数的是代码,注释里的 `push(` 不算。
fn form_code() -> String {
form_body()
.lines()
.map(|line| line.split("//").next().unwrap_or_default())
.collect::<Vec<_>>()
.join("\n")
}
/// 演示 / harness 路径那份只读状态(与 `pages::page` 同源,不伪造第二份)。
fn states() -> States<'static> {
States {
appearance: None,
groups: demo_groups(),
settings: &[],
}
}
/// 判据 ①(**收尾**):正文的柱子只有一个来源——`section_columns` 交出的那一列。
///
/// M195 r2 复审实测的盲区:原来数的是 `stack = stack.push(` 的出现**次数**,于是
/// `stack = stack.push(section).push(frame::demo_card(ui, &[]));` 一句折叠里塞第二件时计数仍是 1
/// (第二个 `.push(` 前面没有 `stack = `)⇒ 判据 ① 与 ② 同时绿。那是在数**写法**。现在按**角色**
/// 断言:循环头直接迭代 `section_columns`、`push(` 恰好一次且实参是那个循环变量、尾表达式恰好是
/// `frame::scroll(ui, stack)`、没有 `extend`/`chain`/`once`/`concat`/`append`。`push(` 按**次数**
/// 数(拆行不影响),注释里的不算(先剥掉每行 `//`)。变异证明见模块文档的「抓得住」。
#[test]
fn the_form_stacks_nothing_but_its_sections() {
let body = form_code();
assert!(
body.contains("for section in section_columns("),
"`form()` 的循环头必须直接迭代 `section_columns(...)`:正文节点的唯一来源就是它"
);
let pushes = body.matches("push(").count();
assert_eq!(
pushes, 1,
"`form()` 里出现了 {pushes} 处 `push(`:分组循环里只该有一处。多出来的那一处就是\
「每组下面多一件」(M196 那张卡)——同一句里折叠成 `.push(a).push(b)` 也算两处"
);
let at = body.find("push(").expect("上面刚数过一次") + "push(".len();
let argument = body[at..].split(')').next().unwrap_or_default().trim();
assert_eq!(
argument, "section",
"被压进正文的必须是 `section_columns` 交出的那一列(循环变量 `section`),实际是 \
`{argument}`:别的来源(演示卡 / 空态 / 任何控件)都不许进正文"
);
for append in ["extend(", "chain(", "once(", "concat(", "append("] {
assert!(
!body.contains(append),
"`form()` 用了 `{append}`:正文只许从 `section_columns` 逐列 push 进来"
);
}
let tail: Vec<&str> = body
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect();
assert_eq!(
tail.iter().rev().take(2).copied().collect::<Vec<_>>(),
vec!["}", "frame::scroll(ui, stack)"],
"`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 language = Language::default();
let narrow = section_columns(&ui, None, states(), language, &update, None);
assert_eq!(
narrow.len(),
GROUPS.len(),
"窄窗(按组堆叠)的正文应当是「每组一件」:多出来的那一件就是 M196 那张卡的位置"
);
for index in 0..GROUPS.len() {
let single = section_columns(&ui, Some(index), states(), language, &update, None);
assert_eq!(
single.len(),
1,
"supporting-pane 只该画被选中的那一组(下标 {index}),实际拿到 {} 列",
single.len()
);
}
}
@@ -159,16 +159,19 @@ pub(super) fn chip_style<'a>(
forced: Option<Interaction>,
) -> impl Fn(&IcedTheme, button::Status) -> button::Style + 'a {
move |_theme, status| {
let state = crate::pages::settings::resolve_state(forced, status);
// 选中档位的底**也是**可交互的(M299):走唯一入口 `selected_fill`——静息逐位等于
// `SecondaryContainer`,hover / pressed 在它上面再叠一层状态层。
let background = if selected {
Some(Background::Color(ui.color(Role::SecondaryContainer)))
} else {
interaction::layer_color(
Some(Background::Color(interaction::selected_fill(
ui,
crate::pages::settings::resolve_state(forced, status),
Role::OnSurface,
frame::BASE.role(),
)
.map(Background::Color)
state,
Role::OnSecondaryContainer,
Role::SecondaryContainer,
)))
} else {
interaction::layer_color(ui, state, Role::OnSurface, frame::BASE.role())
.map(Background::Color)
};
button::Style {
@@ -163,16 +163,19 @@ fn chip_style<'a>(
forced: Option<Interaction>,
) -> impl Fn(&IcedTheme, button::Status) -> button::Style + 'a {
move |_theme, status| {
let state = crate::pages::settings::resolve_state(forced, status);
// 选中档位的底**也是**可交互的(M299):走唯一入口 `selected_fill`——静息逐位等于
// `SecondaryContainer`,hover / pressed 在它上面再叠一层状态层。
let background = if selected {
Some(Background::Color(ui.color(Role::SecondaryContainer)))
} else {
interaction::layer_color(
Some(Background::Color(interaction::selected_fill(
ui,
crate::pages::settings::resolve_state(forced, status),
Role::OnSurface,
frame::BASE.role(),
)
.map(Background::Color)
state,
Role::OnSecondaryContainer,
Role::SecondaryContainer,
)))
} else {
interaction::layer_color(ui, state, Role::OnSurface, frame::BASE.role())
.map(Background::Color)
};
button::Style {
@@ -298,4 +301,79 @@ mod tests {
),
);
}
/// 选中档位的四条:静息 = 实色容器、hover / pressed 各一档、都逐位等于令牌求值(M299)。
///
/// 抓得住:把选中支改回 `Some(ui.color(SecondaryContainer))`(= 选中就不响应)⇒ 全相等,前三条
/// 红;hover 与 pressed 同 α ⇒ 第四条红;合成底换成卡底以外的角色 ⇒ 后两条红。
fn assert_selected_layers(
ui: &Ui,
style: impl Fn(&IcedTheme, button::Status) -> button::Style,
) {
let theme = ui.theme();
let rest = solid(style(&theme, button::Status::Active));
let hover = solid(style(&theme, button::Status::Hovered));
let pressed = solid(style(&theme, button::Status::Pressed));
assert_eq!(
rest,
ui.color(Role::SecondaryContainer),
"选中静息 = 实色容器(不是满档状态层)"
);
assert_ne!(hover, rest, "选中 + hover 必须与静息不同");
assert_ne!(pressed, rest, "选中 + pressed 必须与静息不同");
assert_ne!(hover, pressed, "8% 与 10% 必须能区分");
assert_eq!(
hover,
ui.state_over(
StateLayer::Hover,
Role::OnSecondaryContainer,
Role::SecondaryContainer
)
);
assert_eq!(
pressed,
ui.state_over(
StateLayer::Pressed,
Role::OnSecondaryContainer,
Role::SecondaryContainer
)
);
}
/// **两个选中档位**(主题三档 / 语言三档)都跟态走,且走的是同一个入口。
///
/// 抓不住:像素(`DevDocs/artifacts/m299/settings-general-*-hover|pressed.png`,两枚 chip 都在
/// General 组);也抓不住「自己抄了一份**正确**的叠层」——由 `selection/tests/single_entry.rs`
/// 的接线扫描负责。
#[test]
fn a_selected_mode_chip_reacts_to_hover_and_press() {
use super::super::language::chip_style as language_chip_style;
let ui = ui();
assert_selected_layers(
&ui,
chip_style(
&ui,
true,
ui.color(Role::OnSecondaryContainer),
ui.color(Role::OutlineVariant),
crate::theme::STROKE_PX,
ui.radius(chipbox::CHIP.shape, chipbox::CHIP.height),
None,
),
);
assert_selected_layers(
&ui,
language_chip_style(
&ui,
true,
ui.color(Role::OnSecondaryContainer),
ui.color(Role::OutlineVariant),
crate::theme::STROKE_PX,
ui.radius(chipbox::CHIP.shape, chipbox::CHIP.height),
None,
),
);
}
}
+5 -3
View File
@@ -9,8 +9,10 @@
//! ## 侧列与 rail 的关系(D33)
//!
//! 宽度 240 直接取 `LayoutMetrics::nav_drawer_width`(D33:同一容器 rail 88 ⇄ drawer 240 的展开态),
//! 但**不复制 rail 的实现**:这里没有 hover 药丸、没有二级标签、没有 `Message::Select`。
//! 将来真正的 `NavigationDrawer` 由 M78 提供,本页换掉 [`frame::group_nav`] 即可。
//! 但**不复制 rail 的实现**:这里没有二级标签、没有 `Message::Select`。M299 起分组项也有 hover /
//! pressed 药丸(用户点名的「整个方形区域加深」),但它走设置页自己的 `SelectGroup`,颜色走
//! `interaction::selected_fill` 那个唯一入口(见 `frame::group_item`)。将来真正的
//! `NavigationDrawer` 由 M78 提供,本页换掉 [`frame::group_nav`] 即可。
//!
//! ## 退化(窗口 <720 或 compact 档)
//!
@@ -241,7 +243,7 @@ pub fn view<'a>(
let body = match metrics.layout {
PageLayout::SupportingPane => frame::panes(
metrics,
frame::group_nav(ui, selected, language.effective),
frame::group_nav(ui, selected, language.effective, forced),
frame::left_aligned(
metrics.column,
form(ui, Some(selected), states, language, update, forced),