From 0d917e88dad7da9552fe04416ad15ba0c8f8a242 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 13 Nov 2026 14:41:57 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(settings):=20M299=20=E9=80=89=E4=B8=AD?= =?UTF-8?q?=E9=A1=B9=E6=94=B6=E5=8F=A3=E5=88=B0=20selected=5Ffill=20+=20?= =?UTF-8?q?=E5=88=86=E7=BB=84=E5=AF=BC=E8=88=AA=E6=94=B9=E7=9C=9F=E6=8C=89?= =?UTF-8?q?=E9=92=AE=20+=20tabs=20=E7=94=A8=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 的单测按「纯搬移」拆出去 --- .../src/components/navigation/tabs.rs | 26 +- .../src/components/navigation/tabs/tests.rs | 48 +++ .../src/components/selection/paint/chip.rs | 11 +- .../src/components/selection/tests.rs | 1 + .../src/components/selection/tests/chip.rs | 55 +++ .../selection/tests/single_entry.rs | 188 ++++++++++ .../src/pages/appearance/section/mod.rs | 85 +---- .../src/pages/appearance/section/tests.rs | 118 ++++++ crates/app-iced/src/pages/settings/frame.rs | 335 +++++++++--------- .../settings/frame/no_stray_empty_state.rs | 178 ++++++++++ .../src/pages/settings/general/language.rs | 19 +- .../src/pages/settings/general/theme.rs | 94 ++++- crates/app-iced/src/pages/settings/mod.rs | 8 +- crates/app-iced/src/shell/nav.rs | 88 +---- crates/app-iced/src/shell/nav/account.rs | 52 ++- crates/app-iced/src/shell/nav/tests.rs | 121 +++++++ 16 files changed, 1072 insertions(+), 355 deletions(-) create mode 100644 crates/app-iced/src/components/selection/tests/single_entry.rs create mode 100644 crates/app-iced/src/pages/appearance/section/tests.rs create mode 100644 crates/app-iced/src/pages/settings/frame/no_stray_empty_state.rs create mode 100644 crates/app-iced/src/shell/nav/tests.rs diff --git a/crates/app-iced/src/components/navigation/tabs.rs b/crates/app-iced/src/components/navigation/tabs.rs index cb80db6..0dfe35c 100644 --- a/crates/app-iced/src/components/navigation/tabs.rs +++ b/crates/app-iced/src/components/navigation/tabs.rs @@ -265,6 +265,25 @@ pub fn tabs<'a, M: Clone + 'a>(ui: &'a Ui, tabs: Tabs<'a, M>) -> Element<'a, M> .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, + status: button::Status, +) -> Option { + let state = forced.unwrap_or_else(|| Interaction::from_status(status)); + interaction::layer_color(ui, state, Role::OnSurface, base.role()) +} + /// 一个 tab 单元(内容 + 底部条)。 #[allow(clippy::too_many_arguments)] 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) .height(Length::Fixed(height)) .padding(0) - .style(move |_theme, _status| button::Style { - background: fill.map(Background::Color), + .style(move |_theme, status| button::Style { + // M299:状态层在**闭包里**跟 `status` 求值(此前 `state` 在闭包外算、`status` 被丢掉, + // 真实 hover / pressed 因此一个像素都不变)。 + background: tab_fill(ui, base, forced, status).map(Background::Color), text_color: color, // ContainerShape = CornerNone(tab 本身是直角;圆角只在指示条上) border: Border { diff --git a/crates/app-iced/src/components/navigation/tabs/tests.rs b/crates/app-iced/src/components/navigation/tabs/tests.rs index 1029e50..f43b2de 100644 --- a/crates/app-iced/src/components/navigation/tabs/tests.rs +++ b/crates/app-iced/src/components/navigation/tabs/tests.rs @@ -134,6 +134,54 @@ fn icon_tabs_use_the_taller_container() { 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 的树能建出来(含选中 / 禁用 / 带图标三条路径)。 #[test] fn view_builds_a_tree() { diff --git a/crates/app-iced/src/components/selection/paint/chip.rs b/crates/app-iced/src/components/selection/paint/chip.rs index 369cc1d..83949a7 100644 --- a/crates/app-iced/src/components/selection/paint/chip.rs +++ b/crates/app-iced/src/components/selection/paint/chip.rs @@ -4,7 +4,7 @@ use unlucky_design::color::Role; 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}; /// Chip 的三种(§13.6 / §13.7):Assist / Filter / Input。 @@ -55,7 +55,14 @@ pub fn chip( let outline = (!selected).then(|| ui.color(Role::OutlineVariant)); 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 { state_layer(ui, base, state, label_role) }; diff --git a/crates/app-iced/src/components/selection/tests.rs b/crates/app-iced/src/components/selection/tests.rs index 591726a..0e36115 100644 --- a/crates/app-iced/src/components/selection/tests.rs +++ b/crates/app-iced/src/components/selection/tests.rs @@ -9,6 +9,7 @@ use unlucky_design::typography::Platform; use super::super::interaction::{self, Base, Interaction}; mod chip; +mod single_entry; use super::geometry::{ChipGeometry, ControlGeometry, SwitchGeometry}; use super::paint::{self, ChipVariant}; use crate::theme::{Families, Ui}; diff --git a/crates/app-iced/src/components/selection/tests/chip.rs b/crates/app-iced/src/components/selection/tests/chip.rs index b57a60a..a6ed0a3 100644 --- a/crates/app-iced/src/components/selection/tests/chip.rs +++ b/crates/app-iced/src/components/selection/tests/chip.rs @@ -66,6 +66,61 @@ fn chip_roles_follow_the_tokens() { 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` 给)。 #[test] fn state_layer_only_exists_on_lifted_states() { diff --git a/crates/app-iced/src/components/selection/tests/single_entry.rs b/crates/app-iced/src/components/selection/tests/single_entry.rs new file mode 100644 index 0000000..2d09638 --- /dev/null +++ b/crates/app-iced/src/components/selection/tests/single_entry.rs @@ -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) { + 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)`——\ + 静息它就是实色容器,叠层交给它。" + ); +} diff --git a/crates/app-iced/src/pages/appearance/section/mod.rs b/crates/app-iced/src/pages/appearance/section/mod.rs index 72c96cf..bdc2fe6 100644 --- a/crates/app-iced/src/pages/appearance/section/mod.rs +++ b/crates/app-iced/src/pages/appearance/section/mod.rs @@ -297,16 +297,19 @@ fn choice_style<'a>( forced: Option, ) -> 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; diff --git a/crates/app-iced/src/pages/appearance/section/tests.rs b/crates/app-iced/src/pages/appearance/section/tests.rs new file mode 100644 index 0000000..596a5f6 --- /dev/null +++ b/crates/app-iced/src/pages/appearance/section/tests.rs @@ -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 + ) + ); +} diff --git a/crates/app-iced/src/pages/settings/frame.rs b/crates/app-iced/src/pages/settings/frame.rs index 7d19e8d..4e935e2 100644 --- a/crates/app-iced/src/pages/settings/frame.rs +++ b/crates/app-iced/src/pages/settings/frame.rs @@ -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, +) -> 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::>(), ) @@ -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, ) -> 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, + status: button::Status, +) -> Option { + 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::>() - .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!["}", "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 压回静息(取证图要能拍「无状态层」)" + ); } } diff --git a/crates/app-iced/src/pages/settings/frame/no_stray_empty_state.rs b/crates/app-iced/src/pages/settings/frame/no_stray_empty_state.rs new file mode 100644 index 0000000..c0e9606 --- /dev/null +++ b/crates/app-iced/src/pages/settings/frame/no_stray_empty_state.rs @@ -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::>() + .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!["}", "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() + ); + } +} diff --git a/crates/app-iced/src/pages/settings/general/language.rs b/crates/app-iced/src/pages/settings/general/language.rs index 5d1980e..0119f9f 100644 --- a/crates/app-iced/src/pages/settings/general/language.rs +++ b/crates/app-iced/src/pages/settings/general/language.rs @@ -159,16 +159,19 @@ pub(super) fn chip_style<'a>( forced: Option, ) -> 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 { diff --git a/crates/app-iced/src/pages/settings/general/theme.rs b/crates/app-iced/src/pages/settings/general/theme.rs index 2ef5c07..801f901 100644 --- a/crates/app-iced/src/pages/settings/general/theme.rs +++ b/crates/app-iced/src/pages/settings/general/theme.rs @@ -163,16 +163,19 @@ fn chip_style<'a>( forced: Option, ) -> 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, + ), + ); + } } diff --git a/crates/app-iced/src/pages/settings/mod.rs b/crates/app-iced/src/pages/settings/mod.rs index 0dad606..60a8cb2 100644 --- a/crates/app-iced/src/pages/settings/mod.rs +++ b/crates/app-iced/src/pages/settings/mod.rs @@ -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), diff --git a/crates/app-iced/src/shell/nav.rs b/crates/app-iced/src/shell/nav.rs index 517c1fe..d496b80 100644 --- a/crates/app-iced/src/shell/nav.rs +++ b/crates/app-iced/src/shell/nav.rs @@ -261,21 +261,24 @@ pub(super) fn state_layer_box(metrics: LayoutMetrics) -> (f32, f32) { indicator_size(metrics) } -/// 药丸底色(**唯一**实现点):hover / pressed / 过渡帧都画在这块 56×32 上。 +/// 药丸底色(**几何**的唯一实现点):hover / pressed / 过渡帧都画在这块 56×32 上。 /// -/// 选中/未选中**两条分支不许合并**(照 `components/navigation/drawer/item.rs` 的 `pill_color`): -/// 选中项静息是实色 `SecondaryContainer`,缺层时兜底到那个角色;未选中项静息不画药丸, -/// 兜底就必须是 `None`。经 [`interaction::layer_color`] 求值 ⇒ 没有取样控件时**逐位**等于原来的 -/// `state_over(..)`(`--force-state=rest` 的取证图因此一字不变)。 +/// **颜色**只有一个来源:选中支走 [`interaction::selected_fill`](全仓唯一的选中底色入口, +/// M299 收口了这里的私有拷贝),未选中支走 [`interaction::layer_color`]。选中/未选中**两条分支 +/// 不许合并**:选中项静息是实色 `SecondaryContainer`(`selected_fill` 的兜底),未选中项静息 +/// 不画药丸(`layer_color` 给 `None`)。两条都经状态层求值 ⇒ 没有取样控件时**逐位**等于原来的 +/// `state_over(..)` / `ui.color(..)`(`--force-state=rest` 的取证图因此一字不变)。 fn pill_color(ui: &Ui, state: Interaction, selected: bool) -> Option { if selected { - interaction::layer_color( + // 选中项走**全仓唯一的**选中底色入口(M299):静息 = 实色 `SecondaryContainer`, + // hover / pressed / 过渡帧叠在它上面。此前这里有一份私有拷贝,M296 的变异实验证明 + // 它不跟助手一起动。 + Some(interaction::selected_fill( ui, state, Role::OnSecondaryContainer, Role::SecondaryContainer, - ) - .or_else(|| Some(ui.color(Role::SecondaryContainer))) + )) } else { // 导航轨自己画的就是 `MaterialLayer::Canvas`(见 [`rail`] 的 `.style(..)`):状态层要叠在 // **同一个**角色上,否则 hover 色与轨底色对不上。 @@ -295,71 +298,4 @@ pub fn nav_item_height(ui: &Ui, indicator_height: f32) -> f32 { } #[cfg(test)] -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}" - ); - } - } -} +mod tests; diff --git a/crates/app-iced/src/shell/nav/account.rs b/crates/app-iced/src/shell/nav/account.rs index 82b838c..9e4f5b0 100644 --- a/crates/app-iced/src/shell/nav/account.rs +++ b/crates/app-iced/src/shell/nav/account.rs @@ -175,20 +175,22 @@ pub(super) fn account_item<'a>( } } -/// 头像圆的底色(**唯一**实现点):hover / pressed / 过渡帧都画在这个 Ø40 圆上。 +/// 头像圆的底色(**几何**的唯一实现点):hover / pressed / 过渡帧都画在这个 Ø40 圆上。 /// -/// 两条分支不许合并(照 `drawer/item.rs` 的 `pill_color`):已登录的头像静息是实色 -/// `SecondaryContainer`,缺层时兜底到那个角色;未登录的头像静息只有描边、不填底, -/// 兜底就必须是 `None`。 +/// **颜色**只有一个来源:已登录支走 [`interaction::selected_fill`](全仓唯一的选中底色入口, +/// M299 收口了这里的私有拷贝),未登录支走 [`interaction::layer_color`]。两条分支不许合并: +/// 已登录的头像静息是实色 `SecondaryContainer`(`selected_fill` 的兜底),未登录的头像静息只有 +/// 描边、不填底(`layer_color` 给 `None`)。 fn avatar_color(ui: &Ui, state: Interaction, signed_in: bool) -> Option { if signed_in { - interaction::layer_color( + // 同一个唯一入口(M299):已登录头像的静息底是实色 `SecondaryContainer`, + // hover / pressed 叠在它上面——此前这里是第五份私有拷贝。 + Some(interaction::selected_fill( ui, state, Role::OnSecondaryContainer, Role::SecondaryContainer, - ) - .or_else(|| Some(ui.color(Role::SecondaryContainer))) + )) } else { // 头像直接摆在导航轨上(轨画的是 `MaterialLayer::Canvas`):状态层要叠在同一个角色上。 interaction::layer_color( @@ -220,6 +222,42 @@ mod tests { 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] fn the_avatar_slot_fits_the_rail() { use unlucky_design::color::SchemeFlavor; diff --git a/crates/app-iced/src/shell/nav/tests.rs b/crates/app-iced/src/shell/nav/tests.rs new file mode 100644 index 0000000..6ed0e6f --- /dev/null +++ b/crates/app-iced/src/shell/nav/tests.rs @@ -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}" + ); + } +} From 8144714f307ebc018f24c8dfb5b68487ced7e1eb Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 13 Nov 2026 14:56:20 +0800 Subject: [PATCH 2/3] =?UTF-8?q?docs(ui):=20M299=20=E8=A1=A5=E4=B8=89?= =?UTF-8?q?=E5=A4=84=E4=B8=8E=E8=A1=8C=E4=B8=BA=E5=AF=B9=E9=BD=90=E7=9A=84?= =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=88chip=20=E9=80=89=E4=B8=AD=E6=94=AF?= =?UTF-8?q?=E3=80=81tabs=20=E7=8A=B6=E6=80=81=E5=B1=82=E3=80=81=E5=88=86?= =?UTF-8?q?=E7=BB=84=E5=AF=BC=E8=88=AA=E7=9A=84=E5=8D=8A=E5=BE=84=E5=AE=9E?= =?UTF-8?q?=E6=B5=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 只改注释,零行为变化:cargo test -p unlucky-app-iced --all-targets 全绿(1040 + 6)。 --- crates/app-iced/src/components/navigation/tabs.rs | 5 +++++ crates/app-iced/src/components/selection/paint/chip.rs | 4 ++++ crates/app-iced/src/pages/settings/frame.rs | 10 +++++++--- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/app-iced/src/components/navigation/tabs.rs b/crates/app-iced/src/components/navigation/tabs.rs index 0dfe35c..9c0aa16 100644 --- a/crates/app-iced/src/components/navigation/tabs.rs +++ b/crates/app-iced/src/components/navigation/tabs.rs @@ -215,6 +215,11 @@ fn secondary_line_color(ui: &Ui) -> Color { } /// 一行标签页(自带横向滚动)。 +/// ## 状态层画在哪儿(M299) +/// +/// tab 容器**没有**选中实底(选中的标记是下方那条指示条),所以选中与未选中都一样:状态层叠在 +/// `base` 上(`Tabs::base(..)`,默认画布)。M299 之前这一层是在样式闭包**外**算的,真实 +/// hover / pressed 一个像素都不变;现在由 [`tab_fill`] 在闭包里跟 `button::Status` 求值。 pub fn tabs<'a, M: Clone + 'a>(ui: &'a Ui, tabs: Tabs<'a, M>) -> Element<'a, M> { let geometry = TabsGeometry::of(ui); let primary = tabs.variant.is_primary(); diff --git a/crates/app-iced/src/components/selection/paint/chip.rs b/crates/app-iced/src/components/selection/paint/chip.rs index 83949a7..a7bba1c 100644 --- a/crates/app-iced/src/components/selection/paint/chip.rs +++ b/crates/app-iced/src/components/selection/paint/chip.rs @@ -22,6 +22,10 @@ pub enum ChipVariant { /// /// 与 Checkbox/Radio 的区别:Chip 的**状态层就是它自己的容器**(M3 的 chip 没有圆形状态层), /// 所以未选中 + hover 的 fill 是「状态层叠在 surface 上」的实色。 +/// +/// **选中也一样吃状态层**(M299):底是实色 `SecondaryContainer`,hover / pressed 叠在它上面 +/// ——走 [`interaction::selected_fill`] 那个唯一入口。M299 之前这一支写死实色,`state` 参数 +/// 在选中分支里根本没用上(gallery 的 `chip·filter·on` 一行三态逐像素相同)。 pub fn chip( ui: &Ui, base: Base, diff --git a/crates/app-iced/src/pages/settings/frame.rs b/crates/app-iced/src/pages/settings/frame.rs index 4e935e2..3c8a9ad 100644 --- a/crates/app-iced/src/pages/settings/frame.rs +++ b/crates/app-iced/src/pages/settings/frame.rs @@ -104,9 +104,13 @@ pub(super) fn group_nav<'a>( /// /// ## 几何没动(M283 定稿的那套形状语汇) /// -/// 高 = `LM.row_height`、圆角 = [`ShapeScale::Full`](实量半径 = 高/2,就是胶囊),宽 = 列宽, -/// 内边距 `Space::X3`。本 mission 只换**颜色入口**:选中支走 [`interaction::selected_fill`], -/// 未选中支走 [`interaction::layer_color`](叠在侧列自己的卡底 `frame::BASE` 上)。 +/// 高 = `LM.row_height`、圆角 = [`ShapeScale::Full`]、宽 = 列宽,内边距 `Space::X3`。**胶囊是量出来 +/// 的,不是猜的**:从取证图 `DevDocs/artifacts/m299/new/settings-general-light-hover.png` 量到的 +/// 选中项底色 bbox 是 y240-283(高 **44**)、x120-343,且顶边下 1px 处的横向内缩是 **15px** +/// ——半径 22 的胶囊在这个位置的内缩是 r−√(r²−(r−1)²)=15.4,半径 8 的圆角只有 4.1。所以 +/// **半径 = 高/2 = 22,已经是胶囊**,本 mission 只换颜色入口:选中支走 +/// [`interaction::selected_fill`],未选中支走 [`interaction::layer_color`](叠在侧列自己的卡底 +/// `frame::BASE` 上)。 /// /// ## 内容色为什么还是静态的 /// From fa36ccbb667cb016ac576bd5471ba7510b450cd9 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 13 Nov 2026 15:24:44 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(gate):=20M299=20r1=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20tabs=20=E5=88=A4=E6=8D=AE=E6=94=B9=E6=B5=8B=E9=97=AD?= =?UTF-8?q?=E5=8C=85=E6=9C=AC=E8=BA=AB=20+=20=E5=8F=8D=E4=BE=8B=E5=88=A4?= =?UTF-8?q?=E6=8D=AE=E6=8A=97=E9=87=8D=E6=8E=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1:tabs 的样式闭包抽成 tab_style(..)(返回闭包本身,照 chip_style / choice_style 的写法),单测喂的就是交给 button 的那个闭包。上一版只测闭包里的助手 tab_fill(..), 复审把 fill 挪到闭包外、闭包写回 |_theme, _status| 时用例仍全绿——本 mission 在 tabs 上 唯一的代码修复当时没有判据。现在同一变异红(hover 必须叠状态层)。 P2-2:反例判据改成「去空白 + 去花括号」后比对 .or_else(||Some(ui.color( 与 .unwrap_or_else(||ui.color(,换行 / 缩进 / 闭包加括号都躲不过;扫描跳过 .../tests/ 与 tests.rs;新增 KNOWN_COPIES 豁免表(slider handle,scope 外,已报 finding)与 every_known_copy_is_still_there(豁免不许挂空条目)。自证:名单外第 12 个站点用 换行+花括号写法 ⇒ 红(两种形态各一次),删掉后三条全绿。 行数:tab_style 拆到 components/navigation/tabs/style.rs(tabs.rs 398 → 370)。 像素:new 与 new2 两套 24 张逐张 md5 相同 ⇒ 行为中性。 --- .../src/components/navigation/tabs.rs | 42 +----- .../src/components/navigation/tabs/style.rs | 59 ++++++++ .../src/components/navigation/tabs/tests.rs | 39 +++++- .../selection/tests/single_entry.rs | 130 +++++++++++++++--- 4 files changed, 214 insertions(+), 56 deletions(-) create mode 100644 crates/app-iced/src/components/navigation/tabs/style.rs diff --git a/crates/app-iced/src/components/navigation/tabs.rs b/crates/app-iced/src/components/navigation/tabs.rs index 9c0aa16..beafee3 100644 --- a/crates/app-iced/src/components/navigation/tabs.rs +++ b/crates/app-iced/src/components/navigation/tabs.rs @@ -40,11 +40,10 @@ use iced::widget::text::{LineHeight, Wrapping}; use iced::widget::{button, container, scrollable, space, text, Row, Stack}; -use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow}; +use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels}; use unlucky_design::color::Role; use unlucky_design::icons::IconKind; -use unlucky_design::shape::ShapeScale; use unlucky_design::typography::TypeRole; use crate::icons; @@ -54,6 +53,9 @@ use super::super::interaction::{self, Base, Interaction}; use super::super::layout; use super::geometry::{TabsGeometry, TABS_ICON_HEIGHT}; +mod style; +use style::tab_style; + /// 两种 Tabs(`research/12b` §6;M3 的 `PrimaryTabRow` / `SecondaryTabRow`)。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum TabsVariant { @@ -215,11 +217,12 @@ fn secondary_line_color(ui: &Ui) -> Color { } /// 一行标签页(自带横向滚动)。 +/// /// ## 状态层画在哪儿(M299) /// /// tab 容器**没有**选中实底(选中的标记是下方那条指示条),所以选中与未选中都一样:状态层叠在 /// `base` 上(`Tabs::base(..)`,默认画布)。M299 之前这一层是在样式闭包**外**算的,真实 -/// hover / pressed 一个像素都不变;现在由 [`tab_fill`] 在闭包里跟 `button::Status` 求值。 +/// hover / pressed 一个像素都不变;现在由 [`style::tab_style`] 在闭包里跟 `button::Status` 求值。 pub fn tabs<'a, M: Clone + 'a>(ui: &'a Ui, tabs: Tabs<'a, M>) -> Element<'a, M> { let geometry = TabsGeometry::of(ui); let primary = tabs.variant.is_primary(); @@ -270,25 +273,6 @@ pub fn tabs<'a, M: Clone + 'a>(ui: &'a Ui, tabs: Tabs<'a, M>) -> Element<'a, M> .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, - status: button::Status, -) -> Option { - let state = forced.unwrap_or_else(|| Interaction::from_status(status)); - interaction::layer_color(ui, state, Role::OnSurface, base.role()) -} - /// 一个 tab 单元(内容 + 底部条)。 #[allow(clippy::too_many_arguments)] fn tab_cell<'a, M: Clone + 'a>( @@ -374,19 +358,7 @@ fn tab_cell<'a, M: Clone + 'a>( let button = button(stack) .height(Length::Fixed(height)) .padding(0) - .style(move |_theme, status| button::Style { - // M299:状态层在**闭包里**跟 `status` 求值(此前 `state` 在闭包外算、`status` 被丢掉, - // 真实 hover / pressed 因此一个像素都不变)。 - background: tab_fill(ui, base, forced, status).map(Background::Color), - text_color: color, - // ContainerShape = CornerNone(tab 本身是直角;圆角只在指示条上) - border: Border { - radius: ui.radius_dp(ShapeScale::None), - ..Border::default() - }, - shadow: Shadow::default(), - snap: true, - }); + .style(tab_style(ui, base, forced, color)); match tab.on_press { Some(message) => button.on_press(message).into(), diff --git a/crates/app-iced/src/components/navigation/tabs/style.rs b/crates/app-iced/src/components/navigation/tabs/style.rs new file mode 100644 index 0000000..d00240e --- /dev/null +++ b/crates/app-iced/src/components/navigation/tabs/style.rs @@ -0,0 +1,59 @@ +//! tab 按钮的样式闭包(M299 r1:从 `tabs.rs` 拆出来)。 +//! +//! ## 为什么拆文件 +//! +//! 只有行数:`tabs.rs` 加了这个函数之后到 413 行(AGENTS.md §3-1 的单文件 400 行硬线)。拆法是 +//! 「整段搬走 + 一条 `pub(super) use`」,行为零变化。 +//! +//! ## 为什么返回**闭包**而不是普通函数 +//! +//! 单测要拿到的必须是「交给 `button` 的那个闭包」,不是它里面的助手:M299 r1 复审把一个 +//! `tab_fill(..)` 形状的助手留在闭包外先算、闭包写回 `|_theme, _status|`,那条只测助手的用例 +//! **仍然全绿**——本 mission 在 tabs 上唯一的代码修复当时等于没有判据。照 +//! `pages/settings/general/theme.rs` 的 `chip_style` / `appearance/section` 的 `choice_style` +//! 同一条写法,这里把闭包整个交出去,测试直接喂 `button::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`,取证图因此一字不变。 + +use iced::widget::button; +use iced::{Background, Border, Color, Shadow, Theme}; + +use unlucky_design::color::Role; +use unlucky_design::shape::ShapeScale; + +use crate::app::components::interaction::{self, Base, Interaction}; +use crate::theme::Ui; + +/// tab 按钮的样式闭包(**M299**:状态层在闭包里跟 `status` 求值)。 +pub(super) fn tab_style<'a>( + ui: &'a Ui, + base: Base, + forced: Option, + color: Color, +) -> impl Fn(&Theme, button::Status) -> button::Style + 'a { + move |_theme, status| { + let state = forced.unwrap_or_else(|| Interaction::from_status(status)); + button::Style { + background: interaction::layer_color(ui, state, Role::OnSurface, base.role()) + .map(Background::Color), + text_color: color, + // ContainerShape = CornerNone(tab 本身是直角;圆角只在指示条上) + border: Border { + radius: ui.radius_dp(ShapeScale::None), + ..Border::default() + }, + shadow: Shadow::default(), + snap: true, + } + } +} diff --git a/crates/app-iced/src/components/navigation/tabs/tests.rs b/crates/app-iced/src/components/navigation/tabs/tests.rs index f43b2de..3784d44 100644 --- a/crates/app-iced/src/components/navigation/tabs/tests.rs +++ b/crates/app-iced/src/components/navigation/tabs/tests.rs @@ -134,18 +134,33 @@ fn icon_tabs_use_the_taller_container() { assert!(TABS_ICON_HEIGHT > TabsGeometry::of(&ui).height); } -/// **样式闭包必须吃 `status`**(M299):真实 hover / pressed 之前一个像素都不变。 +/// **交给 `button` 的那个样式闭包必须吃 `status`**(M299)。 /// -/// 抓得住:①回到「`state` 在闭包外算 + `|_theme, _status|`」⇒ hover / pressed 都等于静息(前三条 -/// 红);②hover 与 pressed 用同一个 α ⇒ 第四条红;③`forced` 不再优先(取证图会跟着指针漂)⇒ -/// 后两条红。 +/// 这条用例调的是 [`tab_style`] 返回的**闭包本身**,不是它里面的助手——M299 r1 复审实测过: +/// 只测助手的那一版,把「闭包外先算 fill、闭包写回 `|_theme, _status|`」放回来仍然全绿, +/// 于是本次在 tabs 上唯一的代码修复等于没有判据(这就是 P2-1)。 +/// +/// 抓得住:①闭包里把 status 丢掉(`forced.unwrap_or(Enabled)` / 用 `Status::Active` 先算好)⇒ +/// 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 theme = ui.theme(); + // 这里拿到的是**闭包**(不是颜色):`tab_cell` 交给 `button` 的就是它。 + let fill = + |forced, status| match tab_style(&ui, Base::CANVAS, forced, ui.color(Role::OnSurface))( + &theme, status, + ) + .background + { + Some(Background::Color(color)) => Some(color), + Some(other) => panic!("背景必须是实色,得到 {other:?}"), + None => None, + }; let rest = fill(None, button::Status::Active); let hover = fill(None, button::Status::Hovered); @@ -180,6 +195,20 @@ fn the_style_closure_consumes_the_live_status() { pressed, "forced=pressed 时必须画出 pressed" ); + + // 文字色是**静息内容色**(A1 的内容提档没做,见 findings),与 status 无关 + let ink = ui.color(Role::OnSurface); + for status in [ + button::Status::Active, + button::Status::Hovered, + button::Status::Pressed, + ] { + assert_eq!( + tab_style(&ui, Base::CANVAS, None, ink)(&theme, status).text_color, + ink, + "text_color 必须原样透传({status:?})" + ); + } } /// 3 个 tab 的树能建出来(含选中 / 禁用 / 带图标三条路径)。 diff --git a/crates/app-iced/src/components/selection/tests/single_entry.rs b/crates/app-iced/src/components/selection/tests/single_entry.rs index 2d09638..df76560 100644 --- a/crates/app-iced/src/components/selection/tests/single_entry.rs +++ b/crates/app-iced/src/components/selection/tests/single_entry.rs @@ -13,23 +13,28 @@ //! M296 的变异实验就是这么发现外壳那两处的(改助手 ⇒ 外壳药丸不动)。所以这里量的是**接线**, //! 不是值。 //! -//! ## 两条判据 +//! ## 三条判据 //! //! 1. **每个站点都必须出现 `selected_fill(`**(正例):删掉调用 = 退回私有拷贝,红; -//! 2. **除 `interaction/selected.rs` 之外,产品代码里不许出现 `or_else(|| Some(ui.color(`** -//! (反例):那正是旧私有拷贝的形态。整份源码里现在一处都没有,加回来就红。 +//! 2. **产品代码里不许出现旧私有拷贝的两种形态**(反例):`.or_else(|| Some(ui.color(..))` 与 +//! `.unwrap_or_else(|| ui.color(..))`。比对前把空白全部删掉,所以换行 / 缩进 / 加括号都躲不过 +//! (M299 r1 P2-2:上一版是整串字面量,只换排版就能绕过); +//! 3. **[`KNOWN_COPIES`] 里的豁免必须还活着**(豁免不许挂空条目)。 //! //! ## 抓得住 / 抓不住(据实写) //! //! 抓得住:①某个站点被改成 `Some(ui.color(Role::SecondaryContainer))`(用户点名的那条缺陷)—— -//! 反例判据与正例判据至少一条红;②新的第十一处网点直接抄私有拷贝——反例判据红; -//! ③`selected_fill` 被改名 / 移走——全部正例红。 -//! 抓不住:①**语义上正确但写法不同**的私有拷贝(比如 `match layer_color(..) { Some(c) => c, -//! None => ui.color(..) }`)——它不命中 `or_else` 形态,只能靠人读 diff; +//! 反例判据与正例判据至少一条红;②名单外的第十二个站点直接抄私有拷贝(含换行 / 加括号的写法) +//! ——反例判据红(M299 r1 实测过);③`selected_fill` 被改名 / 移走——全部正例红; +//! ④有人把某处收口了却忘了删豁免——判据 3 红。 +//! 抓不住:①**语义上正确但形态不同**的私有拷贝(比如 `match layer_color(..) { Some(c) => c, +//! None => ui.color(..) }`,或先 `let Some(c) = .. else { return ui.color(..) }`)——它不是 +//! `or_else` / `unwrap_or_else` 形态,只能靠人读 diff; //! ②像素(那是 `selection/probe.rs` 与 `DevDocs/artifacts/m299/` 的图); -//! ③站点内部把 `selected_fill` 的返回值丢了不用(正例只证明「调了」)。 +//! ③站点内部把 `selected_fill` 的返回值丢了不用(正例只证明「调了」); +//! ④字符串字面量里恰好含 `//` 的行会被截断(判定只会更松,不会误报)。 //! -//! 这三条盲区写在这里,是因为「门禁绿」最容易被读成「这事有人管」。 +//! 这几条盲区写在这里,是因为「门禁绿」最容易被读成「这事有人管」。 use std::path::{Path, PathBuf}; @@ -63,10 +68,10 @@ fn product_part(text: &str) -> String { 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); - } + // 注释不算代码:文档里解释「旧写法长什么样」不该被判成旧写法。整行注释与行尾注释 + // 都剥掉(`//` 之后的半行同样不算;字符串里恰好有 `//` 只会让判定更松,不会误报)。 + let code = line.split("//").next().unwrap_or_default(); + out.push_str(code); out.push('\n'); continue; } @@ -108,6 +113,53 @@ fn product_source(relative: &str) -> String { product_part(&text) } +/// 旧私有拷贝的**两种**形态(去掉空白后比对:换行 / 缩进 / 括号排版都躲不过)。 +/// +/// 第一种是「状态层有就给、没有就兜底实色」,第二种是 `selected_fill` 自己的函数体写法 +/// (`unwrap_or_else(|| ui.color(container))`)——两者都是「选中项的底」那份拷贝。 +const PRIVATE_COPY_NEEDLES: [&str; 2] = + [".or_else(||Some(ui.color(", ".unwrap_or_else(||ui.color("]; + +/// 把源码归一成「只留标识符与标点」的形态:删空白,**再删闭包的花括号**。 +/// +/// 于是下面三种写法归一成同一个字面量: +/// +/// ```text +/// .or_else(|| Some(ui.color(..))) +/// .or_else(|| Some(ui.color(..)) +/// .or_else(|| { Some(ui.color(..)) }) +/// ``` +/// +/// 删花括号是必要的:M299 r1 P2-2 的绕过写法正是 `or_else(|| { Some(..) })`——只删空白时那个 +/// `{` 会把针挡掉。针本身以 `||` 打头并紧贴 `Some(`/`ui.color(`,所以「中间隔着别的语句」不会误命中 +/// (`;let y=` 之类的标记仍在)。 +fn squeeze(text: &str) -> String { + text.split_whitespace() + .collect::() + .replace(['{', '}'], "") +} + +/// 命中哪一个针(没有就 `None`)。 +fn private_copy(text: &str) -> Option<&'static str> { + let squeezed = squeeze(text); + PRIVATE_COPY_NEEDLES + .iter() + .copied() + .find(|needle| squeezed.contains(needle)) +} + +/// 这个文件是不是**只**在测试里编译。 +/// +/// 两种形态:`.../tests/xxx.rs`(父模块 `#[cfg(test)] mod tests;` 声明的目录)与 `xxx/tests.rs` +/// (同名子模块文件)。它们整体是测试代码——扫它们只会把测试助手(比如 `java/marker.rs` 里 +/// 「容器为空时按 surface 算对比度」的兜底)当成私有拷贝。 +fn is_test_only(path: &Path) -> bool { + path.file_name().is_some_and(|name| name == "tests.rs") + || path + .components() + .any(|component| component.as_os_str() == "tests") +} + /// 十个「当前项」站点,全部必须经 [`selected_fill`] 拿底。 /// /// 前四行是 M296 收口的,后七行是 M299 收口的(`shell/nav.rs` 与 `shell/nav/account.rs` 各一处)。 @@ -144,6 +196,13 @@ fn every_selected_item_goes_through_the_single_entry() { } /// 判据 2(反例):旧私有拷贝的形态在产品代码里一处都不许留。 +/// +/// ## 为什么比对「去掉空白之后」的形态(M299 r1 P2-2) +/// +/// 上一版拿整串 `"or_else(|| Some(ui.color("` 直接 `contains`:复审把同一形态写成 +/// `or_else(|| { Some(ui.color(..)) })`(**只换排版**)就绕过去了——语义完全一样,字面量却不同。 +/// 现在比对前把源文件里的空白全部删掉(`split_whitespace().collect()`),针也缩成无空白的形态; +/// 换行、缩进、括号里的换行都不再影响判定。 #[test] fn the_private_copy_idiom_is_gone() { let mut files = Vec::new(); @@ -170,13 +229,13 @@ fn the_private_copy_idiom_is_gone() { let mut offenders = Vec::new(); for path in files { - if exempt.contains(&path) { + if exempt.contains(&path) || is_test_only(&path) || is_known_copy(&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()); + if let Some(hit) = private_copy(&product_part(&text)) { + offenders.push(format!("{}(命中 {hit})", path.display())); } } assert!( @@ -186,3 +245,42 @@ fn the_private_copy_idiom_is_gone() { 静息它就是实色容器,叠层交给它。" ); } + +/// 已知的**同一形态、本次不动**的站点:`(相对路径, 为什么留着)`。 +/// +/// 单独列出来而不是「用更窄的针绕开」:绕开等于假装它不存在。这一处是滑块的 handle(静息底是 +/// 实心 `Primary`,hover/focus/pressed 叠状态层 —— 与选中项**同一套语义**,换成 +/// `selected_fill(ui, state, OnSurface, Primary)` 是逐位等价的)。 +/// `components/inputs/**` 不在 M299 的 scope,已作为 finding 报给塔。 +const KNOWN_COPIES: [(&str, &str); 1] = [( + "components/inputs/slider/paint.rs", + "滑块 handle 的静息底是实心 Primary,与选中项同一套语义;components/inputs/** 不在 M299 scope,已报 finding", +)]; + +/// 这个文件是不是 [`KNOWN_COPIES`] 里挂过豁免的那一个。 +fn is_known_copy(path: &Path) -> bool { + let src = src_dir(); + KNOWN_COPIES.iter().any(|(relative, _)| { + path.strip_prefix(&src) + .is_ok_and(|tail| tail == Path::new(relative)) + }) +} + +/// 判据 3(豁免要**活着**):[`KNOWN_COPIES`] 里的文件必须**仍然**含那个形态。 +/// +/// 守的是「豁免越挂越多」:谁把那处收口了,这条立刻红,逼他回来删掉豁免行;谁想给自己文件 +/// 挂个空豁免,也红。 +#[test] +fn every_known_copy_is_still_there() { + let stale: Vec<&str> = KNOWN_COPIES + .iter() + .map(|(relative, _)| *relative) + .filter(|relative| private_copy(&product_source(relative)).is_none()) + .collect(); + + assert!( + stale.is_empty(), + "这些豁免已经失效(文件里没有那个形态了):{stale:?}\n\ + 收口了就把它从 `KNOWN_COPIES` 里删掉——豁免表不许留空条目。" + ); +}