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` 里删掉——豁免表不许留空条目。" + ); +}