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:
1 parent
9b6e92c7b4
commit
0d917e88da
16 files changed
+1065
-348
No files matched your search
@@ -265,6 +265,25 @@ pub fn tabs<'a, M: Clone + 'a>(ui: &'a Ui, tabs: Tabs<'a, M>) -> Element<'a, M>
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// tab 容器的状态层颜色(**M299**:`status` 必须在样式闭包**里面**用掉)。
|
||||||
|
///
|
||||||
|
/// 修的是:此前 `tab_cell` 在闭包**外**把 `state` 算成 `forced.unwrap_or(Enabled)`、闭包又写成
|
||||||
|
/// `|_theme, _status|` ⇒ 真实指针的 hover / pressed **一个像素都不变**(只有 gallery 的
|
||||||
|
/// `Tab::state(..)` 强制态画得出来),全仓的 tab 一起中招。
|
||||||
|
///
|
||||||
|
/// 解析顺序:`forced`(取证 / 矩阵的 `.state(..)`)优先,否则 [`Interaction::from_status`]——
|
||||||
|
/// 与 `components/selection/chip.rs` 同一条。颜色经 [`interaction::layer_color`](唯一入口,A8 起
|
||||||
|
/// 带过渡时间轴):没有取样控件时**逐位**等于 `overlay_color`,取证图因此一字不变。
|
||||||
|
fn tab_fill(
|
||||||
|
ui: &Ui,
|
||||||
|
base: Base,
|
||||||
|
forced: Option<Interaction>,
|
||||||
|
status: button::Status,
|
||||||
|
) -> Option<Color> {
|
||||||
|
let state = forced.unwrap_or_else(|| Interaction::from_status(status));
|
||||||
|
interaction::layer_color(ui, state, Role::OnSurface, base.role())
|
||||||
|
}
|
||||||
|
|
||||||
/// 一个 tab 单元(内容 + 底部条)。
|
/// 一个 tab 单元(内容 + 底部条)。
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn tab_cell<'a, M: Clone + 'a>(
|
fn tab_cell<'a, M: Clone + 'a>(
|
||||||
@@ -347,12 +366,13 @@ fn tab_cell<'a, M: Clone + 'a>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let fill = interaction::overlay_color(ui, state, Role::OnSurface, base.role());
|
|
||||||
let button = button(stack)
|
let button = button(stack)
|
||||||
.height(Length::Fixed(height))
|
.height(Length::Fixed(height))
|
||||||
.padding(0)
|
.padding(0)
|
||||||
.style(move |_theme, _status| button::Style {
|
.style(move |_theme, status| button::Style {
|
||||||
background: fill.map(Background::Color),
|
// M299:状态层在**闭包里**跟 `status` 求值(此前 `state` 在闭包外算、`status` 被丢掉,
|
||||||
|
// 真实 hover / pressed 因此一个像素都不变)。
|
||||||
|
background: tab_fill(ui, base, forced, status).map(Background::Color),
|
||||||
text_color: color,
|
text_color: color,
|
||||||
// ContainerShape = CornerNone(tab 本身是直角;圆角只在指示条上)
|
// ContainerShape = CornerNone(tab 本身是直角;圆角只在指示条上)
|
||||||
border: Border {
|
border: Border {
|
||||||
|
|||||||
@@ -134,6 +134,54 @@ fn icon_tabs_use_the_taller_container() {
|
|||||||
assert!(TABS_ICON_HEIGHT > TabsGeometry::of(&ui).height);
|
assert!(TABS_ICON_HEIGHT > TabsGeometry::of(&ui).height);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **样式闭包必须吃 `status`**(M299):真实 hover / pressed 之前一个像素都不变。
|
||||||
|
///
|
||||||
|
/// 抓得住:①回到「`state` 在闭包外算 + `|_theme, _status|`」⇒ hover / pressed 都等于静息(前三条
|
||||||
|
/// 红);②hover 与 pressed 用同一个 α ⇒ 第四条红;③`forced` 不再优先(取证图会跟着指针漂)⇒
|
||||||
|
/// 后两条红。
|
||||||
|
/// 抓不住:像素。gallery 的 tabs 五态行拍的是 **`forced` 支**(矩阵每格 `.state(..)`),
|
||||||
|
/// 它与本函数同源但不是 `status` 支;`status` 支的真像素要真实指针(取证通道开了指针穿透,
|
||||||
|
/// 本机无桌面会话 ⇒ 拍不到)。报告里把这条写成「图证明的是哪一层」。
|
||||||
|
#[test]
|
||||||
|
fn the_style_closure_consumes_the_live_status() {
|
||||||
|
let ui = ui();
|
||||||
|
let fill = |forced, status| tab_fill(&ui, Base::CANVAS, forced, status);
|
||||||
|
|
||||||
|
let rest = fill(None, button::Status::Active);
|
||||||
|
let hover = fill(None, button::Status::Hovered);
|
||||||
|
let pressed = fill(None, button::Status::Pressed);
|
||||||
|
|
||||||
|
assert_eq!(rest, None, "静息:tab 容器透明(状态层不叠)");
|
||||||
|
assert!(hover.is_some(), "hover 必须叠状态层(此前这里恒为 None)");
|
||||||
|
assert!(
|
||||||
|
pressed.is_some(),
|
||||||
|
"pressed 必须叠状态层(此前这里恒为 None)"
|
||||||
|
);
|
||||||
|
assert_ne!(hover, pressed, "8% 与 10% 必须能区分");
|
||||||
|
assert_eq!(
|
||||||
|
hover,
|
||||||
|
interaction::overlay_color(
|
||||||
|
&ui,
|
||||||
|
Interaction::Hovered,
|
||||||
|
Role::OnSurface,
|
||||||
|
Base::CANVAS.role()
|
||||||
|
),
|
||||||
|
"叠的那一层必须逐位等于令牌求值"
|
||||||
|
);
|
||||||
|
|
||||||
|
// forced(取证 / 矩阵)**优先**:它不许被真实 status 盖掉
|
||||||
|
assert_eq!(
|
||||||
|
fill(Some(Interaction::Enabled), button::Status::Hovered),
|
||||||
|
rest,
|
||||||
|
"forced=静息时必须压掉观测到的 hover"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fill(Some(Interaction::Pressed), button::Status::Active),
|
||||||
|
pressed,
|
||||||
|
"forced=pressed 时必须画出 pressed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 3 个 tab 的树能建出来(含选中 / 禁用 / 带图标三条路径)。
|
/// 3 个 tab 的树能建出来(含选中 / 禁用 / 带图标三条路径)。
|
||||||
#[test]
|
#[test]
|
||||||
fn view_builds_a_tree() {
|
fn view_builds_a_tree() {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use unlucky_design::color::Role;
|
|||||||
|
|
||||||
use crate::theme::{Ui, STROKE_PX};
|
use crate::theme::{Ui, STROKE_PX};
|
||||||
|
|
||||||
use super::super::super::interaction::{Base, Interaction};
|
use super::super::super::interaction::{self, Base, Interaction};
|
||||||
use super::{disabled_container, disabled_ink, state_layer, ChipPaint, Frame};
|
use super::{disabled_container, disabled_ink, state_layer, ChipPaint, Frame};
|
||||||
|
|
||||||
/// Chip 的三种(§13.6 / §13.7):Assist / Filter / Input。
|
/// Chip 的三种(§13.6 / §13.7):Assist / Filter / Input。
|
||||||
@@ -55,7 +55,14 @@ pub fn chip(
|
|||||||
|
|
||||||
let outline = (!selected).then(|| ui.color(Role::OutlineVariant));
|
let outline = (!selected).then(|| ui.color(Role::OutlineVariant));
|
||||||
let fill = if selected {
|
let fill = if selected {
|
||||||
Some(ui.color(Role::SecondaryContainer))
|
// 选中的 chip 也一样吃状态层(M299):M3 的 chip 状态层就是容器自己,
|
||||||
|
// 选中后底是实色 `SecondaryContainer`,hover / pressed 叠在它上面。
|
||||||
|
Some(interaction::selected_fill(
|
||||||
|
ui,
|
||||||
|
state,
|
||||||
|
Role::OnSecondaryContainer,
|
||||||
|
Role::SecondaryContainer,
|
||||||
|
))
|
||||||
} else {
|
} else {
|
||||||
state_layer(ui, base, state, label_role)
|
state_layer(ui, base, state, label_role)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use unlucky_design::typography::Platform;
|
|||||||
use super::super::interaction::{self, Base, Interaction};
|
use super::super::interaction::{self, Base, Interaction};
|
||||||
|
|
||||||
mod chip;
|
mod chip;
|
||||||
|
mod single_entry;
|
||||||
use super::geometry::{ChipGeometry, ControlGeometry, SwitchGeometry};
|
use super::geometry::{ChipGeometry, ControlGeometry, SwitchGeometry};
|
||||||
use super::paint::{self, ChipVariant};
|
use super::paint::{self, ChipVariant};
|
||||||
use crate::theme::{Families, Ui};
|
use crate::theme::{Families, Ui};
|
||||||
|
|||||||
@@ -66,6 +66,61 @@ fn chip_roles_follow_the_tokens() {
|
|||||||
assert_ne!(disabled.label, unselected.label, "13.7:禁用文字降到 38%");
|
assert_ne!(disabled.label, unselected.label, "13.7:禁用文字降到 38%");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **选中的 chip 也吃状态层**(M299):M3 的 chip 状态层就是容器自己,选中后底是实色
|
||||||
|
/// `SecondaryContainer`,hover / pressed 叠在它上面。
|
||||||
|
///
|
||||||
|
/// 抓得住:①把选中支改回「写死 `ui.color(SecondaryContainer)`」(`state` 参数没用上)⇒ hover /
|
||||||
|
/// pressed 都等于静息,前三条红;②hover 与 pressed 用同一个 α ⇒ 第四条红;③把静息也叠一层或
|
||||||
|
/// 换成别的角色 ⇒ 第一条逐值断言红。
|
||||||
|
/// 抓不住:像素(gallery selection 矩阵的 `chip·filter·on` 那一行 + `selection/probe.rs`);
|
||||||
|
/// 也抓不住「自己抄了一份**正确**的叠层」——由 `selection/tests/single_entry.rs` 的接线扫描负责。
|
||||||
|
#[test]
|
||||||
|
fn a_selected_chip_reacts_to_hover_and_press() {
|
||||||
|
use unlucky_design::color::StateLayer;
|
||||||
|
|
||||||
|
let ui = ui();
|
||||||
|
let radius = ChipGeometry::of(&ui).radius;
|
||||||
|
let fill = |state| {
|
||||||
|
paint::chip(&ui, Base::CANVAS, ChipVariant::Filter, true, state, radius)
|
||||||
|
.frame
|
||||||
|
.fill
|
||||||
|
.expect("选中 chip 静息也有底")
|
||||||
|
};
|
||||||
|
|
||||||
|
let rest = fill(Interaction::Enabled);
|
||||||
|
let hover = fill(Interaction::Hovered);
|
||||||
|
let pressed = fill(Interaction::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
|
||||||
|
),
|
||||||
|
"叠的那一层必须逐位等于令牌求值"
|
||||||
|
);
|
||||||
|
for color in [rest, hover, pressed] {
|
||||||
|
assert_eq!(color.a, 1.0, "结果必须不透明(D27:CPU/GPU 同像素)");
|
||||||
|
}
|
||||||
|
// 未选中支没变:静息透明、hover 有状态层
|
||||||
|
let unselected = |state| {
|
||||||
|
paint::chip(&ui, Base::CANVAS, ChipVariant::Filter, false, state, radius)
|
||||||
|
.frame
|
||||||
|
.fill
|
||||||
|
};
|
||||||
|
assert_eq!(unselected(Interaction::Enabled), None);
|
||||||
|
assert!(unselected(Interaction::Hovered).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
/// 状态层只在三态存在,且**不是**满档角色色(α 由 `interaction` 给)。
|
/// 状态层只在三态存在,且**不是**满档角色色(α 由 `interaction` 给)。
|
||||||
#[test]
|
#[test]
|
||||||
fn state_layer_only_exists_on_lifted_states() {
|
fn state_layer_only_exists_on_lifted_states() {
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
//! 门禁:**选中项的底色只有一个入口**(M299)。
|
||||||
|
//!
|
||||||
|
//! ## 守的是什么
|
||||||
|
//!
|
||||||
|
//! MD3 的「当前项」在壳里出现十次(菜单项 / 列表行 / 导航项药丸 / 导航轨 / 头像位 / 语言档 /
|
||||||
|
//! 主题档 / 外观档位 / 分组导航 / 版本选择行)。它们守的是**同一条**行为:底是实色
|
||||||
|
//! `SecondaryContainer`,状态层叠在它上面。M296 把这条收成一个函数
|
||||||
|
//! [`selected_fill`](crate::app::components::interaction::selected_fill),M299 把最后五份私有拷贝
|
||||||
|
//! (语言档 / 主题档 / 外观档位 / 选中 chip / 外壳药丸 + 头像)也接了上去。
|
||||||
|
//!
|
||||||
|
//! 为什么必须有这条门禁:**行为测试抓不住「抄了一份正确的」**。把 `layer_color(..).or_else(||
|
||||||
|
//! 实色)` 在某个控件里重写一遍,逐值断言全绿(值相同),而下一个 α 改动只会动到其中一半——
|
||||||
|
//! M296 的变异实验就是这么发现外壳那两处的(改助手 ⇒ 外壳药丸不动)。所以这里量的是**接线**,
|
||||||
|
//! 不是值。
|
||||||
|
//!
|
||||||
|
//! ## 两条判据
|
||||||
|
//!
|
||||||
|
//! 1. **每个站点都必须出现 `selected_fill(`**(正例):删掉调用 = 退回私有拷贝,红;
|
||||||
|
//! 2. **除 `interaction/selected.rs` 之外,产品代码里不许出现 `or_else(|| Some(ui.color(`**
|
||||||
|
//! (反例):那正是旧私有拷贝的形态。整份源码里现在一处都没有,加回来就红。
|
||||||
|
//!
|
||||||
|
//! ## 抓得住 / 抓不住(据实写)
|
||||||
|
//!
|
||||||
|
//! 抓得住:①某个站点被改成 `Some(ui.color(Role::SecondaryContainer))`(用户点名的那条缺陷)——
|
||||||
|
//! 反例判据与正例判据至少一条红;②新的第十一处网点直接抄私有拷贝——反例判据红;
|
||||||
|
//! ③`selected_fill` 被改名 / 移走——全部正例红。
|
||||||
|
//! 抓不住:①**语义上正确但写法不同**的私有拷贝(比如 `match layer_color(..) { Some(c) => c,
|
||||||
|
//! None => ui.color(..) }`)——它不命中 `or_else` 形态,只能靠人读 diff;
|
||||||
|
//! ②像素(那是 `selection/probe.rs` 与 `DevDocs/artifacts/m299/` 的图);
|
||||||
|
//! ③站点内部把 `selected_fill` 的返回值丢了不用(正例只证明「调了」)。
|
||||||
|
//!
|
||||||
|
//! 这三条盲区写在这里,是因为「门禁绿」最容易被读成「这事有人管」。
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// 本 crate 的 `src/`。
|
||||||
|
fn src_dir() -> PathBuf {
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 递归收集 `.rs` 文件。
|
||||||
|
fn rust_files(dir: &Path, collected: &mut Vec<PathBuf>) {
|
||||||
|
let entries =
|
||||||
|
std::fs::read_dir(dir).unwrap_or_else(|error| panic!("读不动 {}:{error}", dir.display()));
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let path = entry.expect("目录项读失败").path();
|
||||||
|
if path.is_dir() {
|
||||||
|
rust_files(&path, collected);
|
||||||
|
} else if path.extension().is_some_and(|ext| ext == "rs") {
|
||||||
|
collected.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 产品代码(剥掉 `#[cfg(test)]` 项与注释行):测试里写角色色是**应该的**。
|
||||||
|
///
|
||||||
|
/// 为什么不能「从第一个 `#[cfg(test)]` 截到文件尾」:`appearance/section/mod.rs` 在正文中间就有
|
||||||
|
/// `#[cfg(test)] super::probe::record(..)` 这种取证钩子,截断会把后面的 `choice_style` 整段吃掉
|
||||||
|
/// (M299 实测:门禁把已经改好的文件判成「没有 selected_fill」)。所以这里数花括号跳**那一个 item**。
|
||||||
|
fn product_part(text: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut lines = text.lines();
|
||||||
|
while let Some(line) = lines.next() {
|
||||||
|
if !line.trim_start().starts_with("#[cfg(test)]") {
|
||||||
|
// 注释行不算代码:文档里解释「旧写法长什么样」不该被判成旧写法。
|
||||||
|
if !line.trim_start().starts_with("//") {
|
||||||
|
out.push_str(line);
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 跳过这个 item:`#[cfg(test)] mod tests;` 是一行声明,带花括号的则数到配平为止。
|
||||||
|
let mut depth: i32 = 0;
|
||||||
|
let mut opened = false;
|
||||||
|
let mut current = line.to_owned();
|
||||||
|
loop {
|
||||||
|
for ch in current.chars() {
|
||||||
|
match ch {
|
||||||
|
'{' => {
|
||||||
|
depth += 1;
|
||||||
|
opened = true;
|
||||||
|
}
|
||||||
|
'}' => depth -= 1,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if opened && depth <= 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !opened && current.trim_end().ends_with(';') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match lines.next() {
|
||||||
|
Some(next) => current = next.to_owned(),
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 相对 `src/` 的路径 → 源码文本(产品部分)。
|
||||||
|
fn product_source(relative: &str) -> String {
|
||||||
|
let path = src_dir().join(relative);
|
||||||
|
let text = std::fs::read_to_string(&path)
|
||||||
|
.unwrap_or_else(|error| panic!("读不动 {}:{error}", path.display()));
|
||||||
|
product_part(&text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 十个「当前项」站点,全部必须经 [`selected_fill`] 拿底。
|
||||||
|
///
|
||||||
|
/// 前四行是 M296 收口的,后七行是 M299 收口的(`shell/nav.rs` 与 `shell/nav/account.rs` 各一处)。
|
||||||
|
const SITES: [&str; 11] = [
|
||||||
|
"app/adopt/pick.rs",
|
||||||
|
"components/inputs/menu.rs",
|
||||||
|
"components/navigation/drawer/item.rs",
|
||||||
|
"components/navigation/list_item.rs",
|
||||||
|
"components/selection/paint/chip.rs",
|
||||||
|
"pages/appearance/section/mod.rs",
|
||||||
|
"pages/settings/frame.rs",
|
||||||
|
"pages/settings/general/language.rs",
|
||||||
|
"pages/settings/general/theme.rs",
|
||||||
|
"shell/nav/account.rs",
|
||||||
|
"shell/nav.rs",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 判据 1(正例):每个站点都调那个唯一入口。
|
||||||
|
#[test]
|
||||||
|
fn every_selected_item_goes_through_the_single_entry() {
|
||||||
|
let missing: Vec<&str> = SITES
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|relative| !product_source(relative).contains("selected_fill("))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
missing.is_empty(),
|
||||||
|
"这些文件里没有 `selected_fill(`:{missing:?}\n\
|
||||||
|
选中项的底只有 `interaction::selected_fill` 一个入口(M296 收口 + M299 收尾)。\
|
||||||
|
自己写一遍 `layer_color(..).or_else(|| 实色)` 会让「改助手」只改到一半——\
|
||||||
|
这不是风格问题,M296 的变异实验在外壳那两处实测过。"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判据 2(反例):旧私有拷贝的形态在产品代码里一处都不许留。
|
||||||
|
#[test]
|
||||||
|
fn the_private_copy_idiom_is_gone() {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
rust_files(&src_dir(), &mut files);
|
||||||
|
assert!(
|
||||||
|
files.len() >= 100,
|
||||||
|
"只扫到 {} 个源文件:扫描逻辑坏了,这条门禁等于没跑",
|
||||||
|
files.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
// `interaction/selected.rs` 自己就是那个入口:它的函数体里允许出现兜底。
|
||||||
|
// 本文件也豁免:判据的「针」本身就得写成那个形态,否则扫不到。
|
||||||
|
let exempt = [
|
||||||
|
src_dir()
|
||||||
|
.join("components")
|
||||||
|
.join("interaction")
|
||||||
|
.join("selected.rs"),
|
||||||
|
src_dir()
|
||||||
|
.join("components")
|
||||||
|
.join("selection")
|
||||||
|
.join("tests")
|
||||||
|
.join("single_entry.rs"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut offenders = Vec::new();
|
||||||
|
for path in files {
|
||||||
|
if exempt.contains(&path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let text = std::fs::read_to_string(&path)
|
||||||
|
.unwrap_or_else(|error| panic!("读不动 {}:{error}", path.display()));
|
||||||
|
if product_part(&text).contains("or_else(|| Some(ui.color(") {
|
||||||
|
offenders.push(path.display().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
offenders.is_empty(),
|
||||||
|
"这些文件里还有「状态层缺了就兜底实色」的私有拷贝:{offenders:?}\n\
|
||||||
|
选中项的底请改成 `interaction::selected_fill(ui, state, content, container)`——\
|
||||||
|
静息它就是实色容器,叠层交给它。"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -297,15 +297,18 @@ fn choice_style<'a>(
|
|||||||
forced: Option<Interaction>,
|
forced: Option<Interaction>,
|
||||||
) -> impl Fn(&IcedTheme, button::Status) -> button::Style + 'a {
|
) -> impl Fn(&IcedTheme, button::Status) -> button::Style + 'a {
|
||||||
move |_theme, status| {
|
move |_theme, status| {
|
||||||
|
let state = crate::pages::settings::resolve_state(forced, status);
|
||||||
|
// 选中档位的底**也是**可交互的(M299):走唯一入口 `selected_fill`——静息逐位等于
|
||||||
|
// `SecondaryContainer`,hover / pressed 在它上面再叠一层状态层。
|
||||||
let background = if selected {
|
let background = if selected {
|
||||||
Some(Background::Color(ui.color(Role::SecondaryContainer)))
|
Some(Background::Color(interaction::selected_fill(
|
||||||
} else {
|
|
||||||
interaction::layer_color(
|
|
||||||
ui,
|
ui,
|
||||||
crate::pages::settings::resolve_state(forced, status),
|
state,
|
||||||
Role::OnSurface,
|
Role::OnSecondaryContainer,
|
||||||
frame::BASE.role(),
|
Role::SecondaryContainer,
|
||||||
)
|
)))
|
||||||
|
} else {
|
||||||
|
interaction::layer_color(ui, state, Role::OnSurface, frame::BASE.role())
|
||||||
.map(Background::Color)
|
.map(Background::Color)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -323,68 +326,4 @@ fn choice_style<'a>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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(),
|
|
||||||
"静息态不叠状态层"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,8 +12,8 @@ pub(super) use super::super::frame::{
|
|||||||
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::{button, column, container, row};
|
||||||
use iced::{Alignment, Background, Border, Element, Length, Padding, Theme};
|
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme};
|
||||||
|
|
||||||
use unlucky_design::color::Role;
|
use unlucky_design::color::Role;
|
||||||
use unlucky_design::component_map::Component;
|
use unlucky_design::component_map::Component;
|
||||||
@@ -23,6 +23,7 @@ use unlucky_design::spacing::Space;
|
|||||||
use unlucky_design::typography::TypeRole;
|
use unlucky_design::typography::TypeRole;
|
||||||
use unlucky_i18n::{Key, Lang};
|
use unlucky_i18n::{Key, Lang};
|
||||||
|
|
||||||
|
use crate::app::components::interaction::{self, Interaction};
|
||||||
use crate::pages::appearance::Change;
|
use crate::pages::appearance::Change;
|
||||||
use crate::pages::{Action, Message};
|
use crate::pages::{Action, Message};
|
||||||
use crate::theme::Ui;
|
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)。
|
/// 左列分组导航(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 catalog = lang.catalog();
|
||||||
let items = column(
|
let items = column(
|
||||||
super::GROUPS
|
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
|
let label = super::GROUP_LABELS
|
||||||
.get(index)
|
.get(index)
|
||||||
.map_or(group.name, |key| catalog.text(*key));
|
.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<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
)
|
)
|
||||||
@@ -84,15 +93,37 @@ pub(super) fn group_nav<'a>(ui: &'a Ui, selected: usize, lang: Lang) -> Element<
|
|||||||
.into()
|
.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>(
|
fn group_item<'a>(
|
||||||
ui: &'a Ui,
|
ui: &'a Ui,
|
||||||
group: &'a Group,
|
group: &'a Group,
|
||||||
label: &'a str,
|
label: &'a str,
|
||||||
index: usize,
|
index: usize,
|
||||||
selected: bool,
|
selected: bool,
|
||||||
|
forced: Option<Interaction>,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
let spec = Component::NavigationItem.spec();
|
let spec = Component::NavigationItem.spec();
|
||||||
let height = ui.metrics().row_height;
|
let height = ui.metrics().row_height;
|
||||||
|
let radius = ui.radius(ShapeScale::Full, height);
|
||||||
|
|
||||||
let content_color = if selected {
|
let content_color = if selected {
|
||||||
ui.color(Role::OnSecondaryContainer)
|
ui.color(Role::OnSecondaryContainer)
|
||||||
@@ -112,22 +143,50 @@ fn group_item<'a>(
|
|||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.height(height)
|
.height(height)
|
||||||
.padding(Padding::from([0.0, ui.space(Space::X3)]))
|
.padding(Padding::from([0.0, ui.space(Space::X3)]))
|
||||||
.align_y(Alignment::Center)
|
.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()
|
|
||||||
});
|
|
||||||
|
|
||||||
// 点选切分组:走页面自己的 `Message::Appearance(SelectGroup)`(只改视图状态,不落盘)。
|
// 点选切分组:走页面自己的 `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)))
|
.on_press(Message::Appearance(Change::SelectGroup(index)))
|
||||||
.into()
|
.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 守门
|
||||||
|
|
||||||
/// M196:设置页正文**不许**在最后一组之后挂一张空态卡。
|
// M299:守门本体搬进 `frame/no_stray_empty_state.rs`。只为行数——分组导航改成真按钮之后
|
||||||
///
|
// 本文件会超过 400 行硬线(AGENTS.md §3-1)。判据一条没改,只是 `include_str!` 的深度变了。
|
||||||
/// ## 守的是哪一件事
|
|
||||||
///
|
|
||||||
/// 用户 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/` 那组图是**当时**
|
|
||||||
/// 的证据,不是将来的防线(静态图只能证明「现在没有」)。
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod no_stray_empty_state {
|
mod no_stray_empty_state;
|
||||||
use unlucky_design::color::SchemeFlavor;
|
|
||||||
|
/// 分组项的底色(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 unlucky_design::typography::Platform;
|
||||||
|
|
||||||
use crate::pages::update::Update;
|
use crate::theme::Families;
|
||||||
use crate::theme::{Families, Ui};
|
|
||||||
|
|
||||||
use super::super::{demo_groups, section_columns, Language, States, GROUPS};
|
fn ui() -> Ui {
|
||||||
|
Ui::with_platform(
|
||||||
/// 设置页的分发点(本文件是它的被调用方)。
|
SchemeFlavor::Light,
|
||||||
const FORM: &str = include_str!("mod.rs");
|
Platform::Windows,
|
||||||
|
Families {
|
||||||
const FAMILIES: Families = Families {
|
|
||||||
text: "Segoe UI",
|
text: "Segoe UI",
|
||||||
display: "Segoe UI",
|
display: "Segoe UI",
|
||||||
mono: "Consolas",
|
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]
|
#[test]
|
||||||
fn the_form_stacks_nothing_but_its_sections() {
|
fn a_selected_group_reacts_to_hover_and_press() {
|
||||||
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 ui = ui();
|
||||||
let update = Update::default();
|
let fill = |status| group_fill(&ui, true, None, status).expect("选中项静息也有底");
|
||||||
let language = Language::default();
|
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!(
|
assert_eq!(
|
||||||
narrow.len(),
|
rest,
|
||||||
GROUPS.len(),
|
ui.color(Role::SecondaryContainer),
|
||||||
"窄窗(按组堆叠)的正文应当是「每组一件」:多出来的那一件就是 M196 那张卡的位置"
|
"选中静息 = 实色容器"
|
||||||
|
);
|
||||||
|
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() {
|
// 未选中:静息**没有底**(那是状态层自己的事),hover 叠在侧列的卡底上
|
||||||
let single = section_columns(&ui, Some(index), states(), language, &update, None);
|
assert_eq!(group_fill(&ui, false, None, button::Status::Active), None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
single.len(),
|
group_fill(&ui, false, None, button::Status::Hovered),
|
||||||
1,
|
Some(ui.state_over(
|
||||||
"supporting-pane 只该画被选中的那一组(下标 {index}),实际拿到 {} 列",
|
StateLayer::Hover,
|
||||||
single.len()
|
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,15 +159,18 @@ pub(super) fn chip_style<'a>(
|
|||||||
forced: Option<Interaction>,
|
forced: Option<Interaction>,
|
||||||
) -> impl Fn(&IcedTheme, button::Status) -> button::Style + 'a {
|
) -> impl Fn(&IcedTheme, button::Status) -> button::Style + 'a {
|
||||||
move |_theme, status| {
|
move |_theme, status| {
|
||||||
|
let state = crate::pages::settings::resolve_state(forced, status);
|
||||||
|
// 选中档位的底**也是**可交互的(M299):走唯一入口 `selected_fill`——静息逐位等于
|
||||||
|
// `SecondaryContainer`,hover / pressed 在它上面再叠一层状态层。
|
||||||
let background = if selected {
|
let background = if selected {
|
||||||
Some(Background::Color(ui.color(Role::SecondaryContainer)))
|
Some(Background::Color(interaction::selected_fill(
|
||||||
} else {
|
|
||||||
interaction::layer_color(
|
|
||||||
ui,
|
ui,
|
||||||
crate::pages::settings::resolve_state(forced, status),
|
state,
|
||||||
Role::OnSurface,
|
Role::OnSecondaryContainer,
|
||||||
frame::BASE.role(),
|
Role::SecondaryContainer,
|
||||||
)
|
)))
|
||||||
|
} else {
|
||||||
|
interaction::layer_color(ui, state, Role::OnSurface, frame::BASE.role())
|
||||||
.map(Background::Color)
|
.map(Background::Color)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -163,15 +163,18 @@ fn chip_style<'a>(
|
|||||||
forced: Option<Interaction>,
|
forced: Option<Interaction>,
|
||||||
) -> impl Fn(&IcedTheme, button::Status) -> button::Style + 'a {
|
) -> impl Fn(&IcedTheme, button::Status) -> button::Style + 'a {
|
||||||
move |_theme, status| {
|
move |_theme, status| {
|
||||||
|
let state = crate::pages::settings::resolve_state(forced, status);
|
||||||
|
// 选中档位的底**也是**可交互的(M299):走唯一入口 `selected_fill`——静息逐位等于
|
||||||
|
// `SecondaryContainer`,hover / pressed 在它上面再叠一层状态层。
|
||||||
let background = if selected {
|
let background = if selected {
|
||||||
Some(Background::Color(ui.color(Role::SecondaryContainer)))
|
Some(Background::Color(interaction::selected_fill(
|
||||||
} else {
|
|
||||||
interaction::layer_color(
|
|
||||||
ui,
|
ui,
|
||||||
crate::pages::settings::resolve_state(forced, status),
|
state,
|
||||||
Role::OnSurface,
|
Role::OnSecondaryContainer,
|
||||||
frame::BASE.role(),
|
Role::SecondaryContainer,
|
||||||
)
|
)))
|
||||||
|
} else {
|
||||||
|
interaction::layer_color(ui, state, Role::OnSurface, frame::BASE.role())
|
||||||
.map(Background::Color)
|
.map(Background::Color)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -9,8 +9,10 @@
|
|||||||
//! ## 侧列与 rail 的关系(D33)
|
//! ## 侧列与 rail 的关系(D33)
|
||||||
//!
|
//!
|
||||||
//! 宽度 240 直接取 `LayoutMetrics::nav_drawer_width`(D33:同一容器 rail 88 ⇄ drawer 240 的展开态),
|
//! 宽度 240 直接取 `LayoutMetrics::nav_drawer_width`(D33:同一容器 rail 88 ⇄ drawer 240 的展开态),
|
||||||
//! 但**不复制 rail 的实现**:这里没有 hover 药丸、没有二级标签、没有 `Message::Select`。
|
//! 但**不复制 rail 的实现**:这里没有二级标签、没有 `Message::Select`。M299 起分组项也有 hover /
|
||||||
//! 将来真正的 `NavigationDrawer` 由 M78 提供,本页换掉 [`frame::group_nav`] 即可。
|
//! pressed 药丸(用户点名的「整个方形区域加深」),但它走设置页自己的 `SelectGroup`,颜色走
|
||||||
|
//! `interaction::selected_fill` 那个唯一入口(见 `frame::group_item`)。将来真正的
|
||||||
|
//! `NavigationDrawer` 由 M78 提供,本页换掉 [`frame::group_nav`] 即可。
|
||||||
//!
|
//!
|
||||||
//! ## 退化(窗口 <720 或 compact 档)
|
//! ## 退化(窗口 <720 或 compact 档)
|
||||||
//!
|
//!
|
||||||
@@ -241,7 +243,7 @@ pub fn view<'a>(
|
|||||||
let body = match metrics.layout {
|
let body = match metrics.layout {
|
||||||
PageLayout::SupportingPane => frame::panes(
|
PageLayout::SupportingPane => frame::panes(
|
||||||
metrics,
|
metrics,
|
||||||
frame::group_nav(ui, selected, language.effective),
|
frame::group_nav(ui, selected, language.effective, forced),
|
||||||
frame::left_aligned(
|
frame::left_aligned(
|
||||||
metrics.column,
|
metrics.column,
|
||||||
form(ui, Some(selected), states, language, update, forced),
|
form(ui, Some(selected), states, language, update, forced),
|
||||||
|
|||||||
@@ -261,21 +261,24 @@ pub(super) fn state_layer_box(metrics: LayoutMetrics) -> (f32, f32) {
|
|||||||
indicator_size(metrics)
|
indicator_size(metrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 药丸底色(**唯一**实现点):hover / pressed / 过渡帧都画在这块 56×32 上。
|
/// 药丸底色(**几何**的唯一实现点):hover / pressed / 过渡帧都画在这块 56×32 上。
|
||||||
///
|
///
|
||||||
/// 选中/未选中**两条分支不许合并**(照 `components/navigation/drawer/item.rs` 的 `pill_color`):
|
/// **颜色**只有一个来源:选中支走 [`interaction::selected_fill`](全仓唯一的选中底色入口,
|
||||||
/// 选中项静息是实色 `SecondaryContainer`,缺层时兜底到那个角色;未选中项静息不画药丸,
|
/// M299 收口了这里的私有拷贝),未选中支走 [`interaction::layer_color`]。选中/未选中**两条分支
|
||||||
/// 兜底就必须是 `None`。经 [`interaction::layer_color`] 求值 ⇒ 没有取样控件时**逐位**等于原来的
|
/// 不许合并**:选中项静息是实色 `SecondaryContainer`(`selected_fill` 的兜底),未选中项静息
|
||||||
/// `state_over(..)`(`--force-state=rest` 的取证图因此一字不变)。
|
/// 不画药丸(`layer_color` 给 `None`)。两条都经状态层求值 ⇒ 没有取样控件时**逐位**等于原来的
|
||||||
|
/// `state_over(..)` / `ui.color(..)`(`--force-state=rest` 的取证图因此一字不变)。
|
||||||
fn pill_color(ui: &Ui, state: Interaction, selected: bool) -> Option<Color> {
|
fn pill_color(ui: &Ui, state: Interaction, selected: bool) -> Option<Color> {
|
||||||
if selected {
|
if selected {
|
||||||
interaction::layer_color(
|
// 选中项走**全仓唯一的**选中底色入口(M299):静息 = 实色 `SecondaryContainer`,
|
||||||
|
// hover / pressed / 过渡帧叠在它上面。此前这里有一份私有拷贝,M296 的变异实验证明
|
||||||
|
// 它不跟助手一起动。
|
||||||
|
Some(interaction::selected_fill(
|
||||||
ui,
|
ui,
|
||||||
state,
|
state,
|
||||||
Role::OnSecondaryContainer,
|
Role::OnSecondaryContainer,
|
||||||
Role::SecondaryContainer,
|
Role::SecondaryContainer,
|
||||||
)
|
))
|
||||||
.or_else(|| Some(ui.color(Role::SecondaryContainer)))
|
|
||||||
} else {
|
} else {
|
||||||
// 导航轨自己画的就是 `MaterialLayer::Canvas`(见 [`rail`] 的 `.style(..)`):状态层要叠在
|
// 导航轨自己画的就是 `MaterialLayer::Canvas`(见 [`rail`] 的 `.style(..)`):状态层要叠在
|
||||||
// **同一个**角色上,否则 hover 色与轨底色对不上。
|
// **同一个**角色上,否则 hover 色与轨底色对不上。
|
||||||
@@ -295,71 +298,4 @@ pub fn nav_item_height(ui: &Ui, indicator_height: f32) -> f32 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests;
|
||||||
use super::*;
|
|
||||||
use unlucky_design::color::SchemeFlavor;
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn indicator_matches_the_reference_56x32() {
|
|
||||||
let (width, height) = indicator_size(LayoutMetrics::DESKTOP);
|
|
||||||
assert_eq!((width, height), (56.0, 32.0), "12b 5.4 的参考值");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn nav_item_height_is_indicator_plus_gap_plus_label() {
|
|
||||||
let ui = ui();
|
|
||||||
let (_, indicator_height) = indicator_size(ui.metrics());
|
|
||||||
// 32 + 4 + 16
|
|
||||||
assert_eq!(nav_item_height(&ui, indicator_height), 52.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn every_nav_item_has_a_label_and_an_icon() {
|
|
||||||
assert!((4..=6).contains(&NavItem::ALL.len()));
|
|
||||||
for item in NavItem::ALL {
|
|
||||||
assert!(!item.label(Lang::En).is_empty(), "{item:?} 没有文案");
|
|
||||||
assert!(!item.icon().icon().path.is_empty(), "{item:?} 没有图标");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// **M108**:导航栏上这一项叫 `Versions`(88px 轨放不下全名 `Version management`)。
|
|
||||||
///
|
|
||||||
/// 文案本身走目录(M106 的 `label_key()` + `label(lang)`),这条只守**取值**与**宽度余量**:
|
|
||||||
///
|
|
||||||
/// - **抓得住**:① 有人把 `Key::NavDownload` 的英文改回「下载」或塞回 18 字符的全名
|
|
||||||
/// (88px 轨上会截断/换行,实测全名 ≈101px);② 中文被「为了对称」砍成两个字。
|
|
||||||
/// - **抓不住**:真画出来的截断(那要出图,见 M108 报告里的产品通道与 pages_demo 图);
|
|
||||||
/// 也抓不住 240 展开抽屉(那一档在产品里还不可达)。
|
|
||||||
#[test]
|
|
||||||
fn the_download_item_is_labelled_version_management() {
|
|
||||||
// 88px 轨的余量:中文 4 字 ≈48px、英文短名 ≈45px,都该有明显的余量
|
|
||||||
assert_eq!(NavItem::Download.label(Lang::En), "Versions");
|
|
||||||
assert_eq!(NavItem::Download.label(Lang::ZhCn), "版本管理");
|
|
||||||
for item in NavItem::ALL {
|
|
||||||
let label = item.label(Lang::En);
|
|
||||||
assert!(
|
|
||||||
!label.to_ascii_lowercase().contains("download"),
|
|
||||||
"{item:?} 的英文标签还在说下载:{label}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
label.chars().count() <= 12,
|
|
||||||
"{item:?} 的标签太长,88px 的轨放不下:{label}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -175,20 +175,22 @@ pub(super) fn account_item<'a>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 头像圆的底色(**唯一**实现点):hover / pressed / 过渡帧都画在这个 Ø40 圆上。
|
/// 头像圆的底色(**几何**的唯一实现点):hover / pressed / 过渡帧都画在这个 Ø40 圆上。
|
||||||
///
|
///
|
||||||
/// 两条分支不许合并(照 `drawer/item.rs` 的 `pill_color`):已登录的头像静息是实色
|
/// **颜色**只有一个来源:已登录支走 [`interaction::selected_fill`](全仓唯一的选中底色入口,
|
||||||
/// `SecondaryContainer`,缺层时兜底到那个角色;未登录的头像静息只有描边、不填底,
|
/// M299 收口了这里的私有拷贝),未登录支走 [`interaction::layer_color`]。两条分支不许合并:
|
||||||
/// 兜底就必须是 `None`。
|
/// 已登录的头像静息是实色 `SecondaryContainer`(`selected_fill` 的兜底),未登录的头像静息只有
|
||||||
|
/// 描边、不填底(`layer_color` 给 `None`)。
|
||||||
fn avatar_color(ui: &Ui, state: Interaction, signed_in: bool) -> Option<Color> {
|
fn avatar_color(ui: &Ui, state: Interaction, signed_in: bool) -> Option<Color> {
|
||||||
if signed_in {
|
if signed_in {
|
||||||
interaction::layer_color(
|
// 同一个唯一入口(M299):已登录头像的静息底是实色 `SecondaryContainer`,
|
||||||
|
// hover / pressed 叠在它上面——此前这里是第五份私有拷贝。
|
||||||
|
Some(interaction::selected_fill(
|
||||||
ui,
|
ui,
|
||||||
state,
|
state,
|
||||||
Role::OnSecondaryContainer,
|
Role::OnSecondaryContainer,
|
||||||
Role::SecondaryContainer,
|
Role::SecondaryContainer,
|
||||||
)
|
))
|
||||||
.or_else(|| Some(ui.color(Role::SecondaryContainer)))
|
|
||||||
} else {
|
} else {
|
||||||
// 头像直接摆在导航轨上(轨画的是 `MaterialLayer::Canvas`):状态层要叠在同一个角色上。
|
// 头像直接摆在导航轨上(轨画的是 `MaterialLayer::Canvas`):状态层要叠在同一个角色上。
|
||||||
interaction::layer_color(
|
interaction::layer_color(
|
||||||
@@ -220,6 +222,42 @@ mod tests {
|
|||||||
assert!(compacted.compact, "compact(true) 必须收成图标条");
|
assert!(compacted.compact, "compact(true) 必须收成图标条");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **已登录头像也跟态走**,且走的是全仓唯一的那个入口(M299)。
|
||||||
|
///
|
||||||
|
/// 抓得住:把 `signed_in` 支改回「选中就不叠层」⇒ hover / pressed 都等于静息,前两条红;
|
||||||
|
/// hover 与 pressed 同源 ⇒ 第三条红。未登录支(只有描边、不填底)一并钉住。
|
||||||
|
/// 抓不住:像素(`DevDocs/artifacts/m299/shell-account-*`);Ø40 几何本次没动。
|
||||||
|
#[test]
|
||||||
|
fn the_signed_in_avatar_reacts_to_hover_and_press() {
|
||||||
|
use unlucky_design::color::SchemeFlavor;
|
||||||
|
use unlucky_design::typography::Platform;
|
||||||
|
|
||||||
|
use crate::theme::Families;
|
||||||
|
|
||||||
|
let ui = Ui::with_platform(
|
||||||
|
SchemeFlavor::Light,
|
||||||
|
Platform::Windows,
|
||||||
|
Families {
|
||||||
|
text: "Segoe UI",
|
||||||
|
display: "Segoe UI",
|
||||||
|
mono: "Consolas",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let fill = |state| avatar_color(&ui, state, true).expect("已登录静息也有底");
|
||||||
|
let rest = fill(Interaction::Enabled);
|
||||||
|
let hover = fill(Interaction::Hovered);
|
||||||
|
let pressed = fill(Interaction::Pressed);
|
||||||
|
|
||||||
|
assert_eq!(rest, ui.color(Role::SecondaryContainer));
|
||||||
|
assert_ne!(hover, rest, "已登录 + hover 必须与静息不同");
|
||||||
|
assert_ne!(pressed, rest, "已登录 + pressed 必须与静息不同");
|
||||||
|
assert_ne!(hover, pressed, "8% 与 10% 必须能区分");
|
||||||
|
|
||||||
|
// 未登录:静息不填底,hover 才有状态层
|
||||||
|
assert_eq!(avatar_color(&ui, Interaction::Enabled, false), None);
|
||||||
|
assert!(avatar_color(&ui, Interaction::Hovered, false).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_avatar_slot_fits_the_rail() {
|
fn the_avatar_slot_fits_the_rail() {
|
||||||
use unlucky_design::color::SchemeFlavor;
|
use unlucky_design::color::SchemeFlavor;
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
//! `shell/nav.rs` 的单测(M299 从 `nav.rs` 纯搬移,守 400 行硬线)。
|
||||||
|
//!
|
||||||
|
//! 判据与注释一字未动,只是**换文件**:`nav.rs` 加了一条「选中药丸跟态走」之后到 413 行。
|
||||||
|
//! 模块路径仍是 `shell::nav::tests`(`nav.rs` 里改成 `mod tests;`),`super::*` 拿到的还是
|
||||||
|
//! `shell::nav`。
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use unlucky_design::color::SchemeFlavor;
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn indicator_matches_the_reference_56x32() {
|
||||||
|
let (width, height) = indicator_size(LayoutMetrics::DESKTOP);
|
||||||
|
assert_eq!((width, height), (56.0, 32.0), "12b 5.4 的参考值");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **选中药丸也跟态走**,且走的是全仓唯一的那个入口(M299)。
|
||||||
|
///
|
||||||
|
/// 抓得住:①把选中支改回「选中就不叠层」(`Some(ui.color(SecondaryContainer))`)⇒ hover /
|
||||||
|
/// pressed 都等于静息,前两条红;②把 hover 的 α 抄成 pressed 的 10% ⇒ `hover != pressed` 红;
|
||||||
|
/// ③把选中支的合成底写成画布色(导航轨自己画的是 `MaterialLayer::Canvas`,但选中项的
|
||||||
|
/// 状态层要叠在 `SecondaryContainer` 上)⇒ 后两条逐值断言红。
|
||||||
|
/// 抓不住:像素(`DevDocs/artifacts/m299/shell-*-hover|pressed.png`)与**几何**(56×32 由
|
||||||
|
/// `state_layer_box` / `indicator_size` 那两条钉住,本次一个像素都没动);也抓不住「这一处
|
||||||
|
/// 自己抄了一份**正确**的叠层」——那是 `selection/tests/single_entry.rs` 的接线扫描管的事。
|
||||||
|
#[test]
|
||||||
|
fn the_selected_pill_reacts_to_hover_and_press() {
|
||||||
|
use unlucky_design::color::StateLayer;
|
||||||
|
|
||||||
|
let ui = ui();
|
||||||
|
let fill = |state| pill_color(&ui, state, true).expect("选中项静息也有底");
|
||||||
|
let rest = fill(Interaction::Enabled);
|
||||||
|
let hover = fill(Interaction::Hovered);
|
||||||
|
let pressed = fill(Interaction::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
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// 未选中支**没变**:静息不画药丸,三态仍是各自的状态层
|
||||||
|
assert_eq!(pill_color(&ui, Interaction::Enabled, false), None);
|
||||||
|
assert!(pill_color(&ui, Interaction::Hovered, false).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nav_item_height_is_indicator_plus_gap_plus_label() {
|
||||||
|
let ui = ui();
|
||||||
|
let (_, indicator_height) = indicator_size(ui.metrics());
|
||||||
|
// 32 + 4 + 16
|
||||||
|
assert_eq!(nav_item_height(&ui, indicator_height), 52.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_nav_item_has_a_label_and_an_icon() {
|
||||||
|
assert!((4..=6).contains(&NavItem::ALL.len()));
|
||||||
|
for item in NavItem::ALL {
|
||||||
|
assert!(!item.label(Lang::En).is_empty(), "{item:?} 没有文案");
|
||||||
|
assert!(!item.icon().icon().path.is_empty(), "{item:?} 没有图标");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **M108**:导航栏上这一项叫 `Versions`(88px 轨放不下全名 `Version management`)。
|
||||||
|
///
|
||||||
|
/// 文案本身走目录(M106 的 `label_key()` + `label(lang)`),这条只守**取值**与**宽度余量**:
|
||||||
|
///
|
||||||
|
/// - **抓得住**:① 有人把 `Key::NavDownload` 的英文改回「下载」或塞回 18 字符的全名
|
||||||
|
/// (88px 轨上会截断/换行,实测全名 ≈101px);② 中文被「为了对称」砍成两个字。
|
||||||
|
/// - **抓不住**:真画出来的截断(那要出图,见 M108 报告里的产品通道与 pages_demo 图);
|
||||||
|
/// 也抓不住 240 展开抽屉(那一档在产品里还不可达)。
|
||||||
|
#[test]
|
||||||
|
fn the_download_item_is_labelled_version_management() {
|
||||||
|
// 88px 轨的余量:中文 4 字 ≈48px、英文短名 ≈45px,都该有明显的余量
|
||||||
|
assert_eq!(NavItem::Download.label(Lang::En), "Versions");
|
||||||
|
assert_eq!(NavItem::Download.label(Lang::ZhCn), "版本管理");
|
||||||
|
for item in NavItem::ALL {
|
||||||
|
let label = item.label(Lang::En);
|
||||||
|
assert!(
|
||||||
|
!label.to_ascii_lowercase().contains("download"),
|
||||||
|
"{item:?} 的英文标签还在说下载:{label}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
label.chars().count() <= 12,
|
||||||
|
"{item:?} 的标签太长,88px 的轨放不下:{label}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user