From 638e2a3efe23426d53754625b60e604615b510b3 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 13 Nov 2026 12:58:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(app-iced):=20=E9=80=89=E4=B8=AD=E9=A1=B9?= =?UTF-8?q?=E5=8F=A0=E7=8A=B6=E6=80=81=E5=B1=82=E6=94=B6=E5=8F=A3=E5=88=B0?= =?UTF-8?q?=E4=B8=80=E5=A4=84=EF=BC=8C=E5=AF=BC=E5=85=A5=E5=90=91=E5=AF=BC?= =?UTF-8?q?=E7=9A=84=E7=89=88=E6=9C=AC=E8=A1=8C=E4=B8=8D=E5=86=8D=E3=80=8C?= =?UTF-8?q?=E9=80=89=E4=B8=AD=E5=B0=B1=E6=B2=A1=E5=8F=8D=E9=A6=88=E3=80=8D?= =?UTF-8?q?=EF=BC=88M296=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 2026-11-13:已选中的控件在悬停/按下时没有任何反馈。塔裁决「要加」—— MD3 的行为是选中容器(SecondaryContainer)上**再叠**状态层,两者不互斥。 枚举(全仓 grep SecondaryContainer / selected + fill,表见报告)后事实是 3/4 已做: 菜单项(inputs/menu.rs)、列表行(navigation/list_item.rs)、导航项药丸 (navigation/drawer/item.rs)各自写了一遍同一个 6 行形状;真正「死」的是导入向导的 版本选择行(app/adopt/pick.rs:底色写死在容器样式里,按钮样式恒 None)。 三件事: 1. 抽 interaction::selected_fill(ui, state, content, container)——第三次法则的落点, 与未选中态的 layer_color 分两条(未选中静息没有底,选中项静息有底); 2. 三处拷贝收敛到它(menu / list_item 从 overlay_color 升到带时间轴的 layer_color, 无取样控件时逐位相同;drawer 只换入口); 3. pick.rs 的候选行底色搬进 button 的样式闭包 → 选中行按 hover 8% / pressed 10% 叠层; 未选中行仍恒 None。 判据:rest 图 16/16 md5 与改动前全等(含 adopt-pick-selected);列表选中行 rest/hover/pressed 三色互异(浅 212,228,246 / 196,212,230 / 192,208,225);变异把 selected_fill 改回「不叠」→ 7 条单测红 + 该行三张图逐像素相同(1/3)。 全量 cargo test -p unlucky-app-iced --lib:1017 passed / 0 failed。 --- crates/app-iced/src/app/adopt/pick.rs | 131 ++++++++++-- crates/app-iced/src/components/inputs/menu.rs | 16 +- .../src/components/inputs/menu/tests.rs | 4 + .../src/components/interaction/mod.rs | 9 + .../src/components/interaction/selected.rs | 191 ++++++++++++++++++ .../src/components/navigation/drawer/item.rs | 13 +- .../src/components/navigation/drawer/tests.rs | 21 ++ .../src/components/navigation/list_item.rs | 14 +- .../components/navigation/list_item/tests.rs | 33 +++ 9 files changed, 391 insertions(+), 41 deletions(-) create mode 100644 crates/app-iced/src/components/interaction/selected.rs diff --git a/crates/app-iced/src/app/adopt/pick.rs b/crates/app-iced/src/app/adopt/pick.rs index a3467fa..097ee2c 100644 --- a/crates/app-iced/src/app/adopt/pick.rs +++ b/crates/app-iced/src/app/adopt/pick.rs @@ -17,6 +17,7 @@ use unlucky_i18n::{Catalog, Key}; use unlucky_presenter::dto::{AdoptCandidateView, AdoptPlanView, AdoptScanView, AdoptTargetView}; use crate::app::components::inputs::{text_field, Field}; +use crate::app::components::interaction::{self, Interaction}; use crate::app::components::selection::{chip, Chip}; use crate::pages::frame; use crate::theme::Ui; @@ -124,36 +125,66 @@ fn candidate_row<'a>( items = items.push(problem_line(ui, problem)); } + // 行体:底色**不在这里**(选中行要跟 hover/pressed 走,见下面 `button` 的样式闭包)。 + // 不可点的行没有交互,静息实色照旧画在这个容器上。 + let radius = ui.radius_dp(ShapeScale::Medium); let body = container(items) .width(Length::Fill) - .padding(ui.space(Sp::X2)) - .style(move |_theme| container::Style { - background: selected.then(|| Background::Color(ui.color(Role::SecondaryContainer))), - border: Border { - radius: ui.radius_dp(ShapeScale::Medium), - ..Border::default() - }, - ..container::Style::default() - }); + .padding(ui.space(Sp::X2)); if !candidate.adoptable() { // 不可导入的行**不可点**(也就不存在「点了才被告知不行」这条路) - return body.into(); + return body + .style(move |_theme| container::Style { + background: row_fill(ui, Interaction::Enabled, selected).map(Background::Color), + border: Border { + radius, + ..Border::default() + }, + ..container::Style::default() + }) + .into(); } + // 选中行的底色 = `SecondaryContainer` **叠**状态层(M296):hover 8% / pressed 10%, + // 与菜单项 / 列表行 / 导航项同一个入口(`interaction::selected_fill`)——「选中了就不响应」 + // 是死控件,不是 Material。未选中的行背景恒 `None`(逐字节等于 M296 之前)。 + let style = move |_theme: &iced::Theme, status: button::Status| button::Style { + background: row_fill(ui, Interaction::from_status(status), selected).map(Background::Color), + text_color: Color::TRANSPARENT, + border: Border { + radius, + ..Border::default() + }, + shadow: iced::Shadow::default(), + snap: true, + }; button(body) .width(Length::Fill) .padding(0) - .style(|_theme, _status| button::Style { - background: None, - text_color: Color::TRANSPARENT, - border: Border::default(), - shadow: iced::Shadow::default(), - snap: true, - }) + .style(style) .on_press(adopt(AdoptMessage::Select(candidate.version_id.clone()))) .into() } +/// 一条候选行的底色(`None` = 不画,透出向导卡片的底)。 +/// +/// - 未选中:恒 `None`——M296 之前行为**逐字节不变**(那时也是 `selected.then(..)`); +/// - 选中:`SecondaryContainer` 叠状态层(唯一入口 [`interaction::selected_fill`]), +/// 静息就是实色、hover 8%、pressed 10%。 +/// +/// 抽成一个函数是为了让它**可测**:样式闭包拿不到(`Element` 没有内省),而 +/// 「选中项在 hover/pressed 下必须变色」是 M296 的判据之一。 +fn row_fill(ui: &Ui, state: Interaction, selected: bool) -> Option { + selected.then(|| { + interaction::selected_fill( + ui, + state, + Role::OnSecondaryContainer, + Role::SecondaryContainer, + ) + }) +} + /// 候选右侧那枚状态(可导入 / N 个问题 / 已被某实例使用)。 fn status<'a>( ui: &'a Ui, @@ -226,3 +257,69 @@ fn target_hint<'a>(ui: &'a Ui, target: AdoptTargetView, catalog: Catalog) -> Ele }; wrapped(ui, catalog.text(key).to_string(), color) } + +#[cfg(test)] +mod tests { + //! 候选行底色的判据(M296):选中行**也**吃 hover / pressed。 + + use super::*; + use unlucky_design::color::SchemeFlavor; + use unlucky_design::typography::Platform; + + use crate::theme::Families; + + fn ui(flavor: SchemeFlavor) -> Ui { + Ui::with_platform( + flavor, + Platform::Windows, + Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }, + ) + } + + /// 未选中的行**永远**不画底(M296 之前也是 `selected.then(..)`)。 + /// + /// 抓得住:有人为了让「未选中也有反馈」把底色给到未选中行——那会整列变色, + /// `--screenshot` 的 rest 图 md5 与「未选中态逐字节不变」的判据立刻红。 + #[test] + fn an_unselected_candidate_row_never_paints_a_background() { + let ui = ui(SchemeFlavor::Light); + for state in Interaction::ALL { + assert_eq!(row_fill(&ui, state, false), None, "{state:?} 未选中不画底"); + } + } + + /// 选中行:静息实色,hover / pressed 叠状态层,三态两两不同。 + /// + /// 抓得住:选中分支写回「选中就不叠层」(`hover == pressed == rest` 一起红)。 + /// 抓不住:真画出来的像素——向导没有 `--force-state` 通道(见 M296 报告的「没验的部分」), + /// 所以这一条只钉颜色求值,图上的证据只有 rest 一张。 + #[test] + fn a_selected_candidate_row_reacts_to_hover_and_press() { + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let ui = ui(flavor); + let fill = |state| row_fill(&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, "{flavor:?}:选中 + hover 必须与静息不同"); + assert_ne!(pressed, rest, "{flavor:?}:选中 + pressed 必须与静息不同"); + assert_ne!(hover, pressed, "{flavor:?}:8% 与 10% 必须能区分"); + assert_eq!(hover.a, 1.0, "{flavor:?}:结果必须不透明(D27)"); + assert_eq!( + hover, + interaction::selected_fill( + &ui, + Interaction::Hovered, + Role::OnSecondaryContainer, + Role::SecondaryContainer + ), + "{flavor:?}:叠层必须经唯一入口求值" + ); + } + } +} diff --git a/crates/app-iced/src/components/inputs/menu.rs b/crates/app-iced/src/components/inputs/menu.rs index 488521c..5dc2e7c 100644 --- a/crates/app-iced/src/components/inputs/menu.rs +++ b/crates/app-iced/src/components/inputs/menu.rs @@ -192,16 +192,14 @@ fn item_palette(ui: &Ui, base: Base, state: Interaction, selected: bool) -> Item } if selected { - // 选中项也要跟态走:状态层叠在 SecondaryContainer 上(不透明结果,D27) - let fill = interaction::overlay_color( - ui, - state, - Role::OnSecondaryContainer, - Role::SecondaryContainer, - ) - .unwrap_or_else(|| ui.color(Role::SecondaryContainer)); + // 选中项也要跟态走:状态层叠在 SecondaryContainer 上(唯一入口 `interaction::selected_fill`) return ItemPalette { - fill: Some(fill), + fill: Some(interaction::selected_fill( + ui, + state, + Role::OnSecondaryContainer, + Role::SecondaryContainer, + )), ink: ui.color(Role::OnSecondaryContainer), icon: ui.color(Role::OnSecondaryContainer), shape: ShapeScale::Small, diff --git a/crates/app-iced/src/components/inputs/menu/tests.rs b/crates/app-iced/src/components/inputs/menu/tests.rs index 84c0f33..2bab1f9 100644 --- a/crates/app-iced/src/components/inputs/menu/tests.rs +++ b/crates/app-iced/src/components/inputs/menu/tests.rs @@ -72,6 +72,10 @@ fn selected_and_disabled_items_use_the_right_roles() { Role::SecondaryContainer ) ); + // 选中 + pressed:M296 判据 2 的单测半边——10% 与 hover 的 8% 必须能区分 + let pressed = item_palette(&ui, PANEL_BASE, Interaction::Pressed, true); + assert_ne!(pressed.fill, selected.fill, "选中 + pressed 必须与静息不同"); + assert_ne!(pressed.fill, hovered.fill, "8% 与 10% 必须能区分"); let disabled = item_palette(&ui, PANEL_BASE, Interaction::Disabled, true); assert_eq!(disabled.fill, None, "禁用项不留满档选中底色"); diff --git a/crates/app-iced/src/components/interaction/mod.rs b/crates/app-iced/src/components/interaction/mod.rs index b90ba65..6f640a3 100644 --- a/crates/app-iced/src/components/interaction/mod.rs +++ b/crates/app-iced/src/components/interaction/mod.rs @@ -46,6 +46,13 @@ //! M229 之前这个值恒等于 `Role::Surface`,于是卡片/面板里的禁用控件把 10%/12% 叠在画布色上 //! (M223 的「CR 1.05、形丢失」)。**不许**在入口内部再写一个默认值回来。 //! +//! ## 选中态有它自己的入口(M296) +//! +//! 选中项的底是**实色**(`SecondaryContainer`),状态层叠在它上面 —— 所以它不是 +//! [`layer_color`] 的「`None` 就是不画底」那种情形,而是 [`selected_fill`](静息 = 实色容器)。 +//! 菜单项 / 列表行 / 导航项 / 版本选择行一律调那一个入口,**不许**各写一份 +//! `overlay_color(..).or_else(|| 实色)`。两条分支不许合并:未选中的静息没有底,选中项的静息有底。 +//! //! ## 两个消费形态 //! //! 1. **iced `button` 系**(本 mission 的按钮家族):拿 [`Interaction`] 直接配 [`overlay_color`]; @@ -190,11 +197,13 @@ pub fn layer_color(ui: &Ui, state: Interaction, content: Role, back: Role) -> Op mod base; mod disabled; +mod selected; pub use base::Base; pub use disabled::{ disabled_button_colors, disabled_button_content, disabled_colors, disabled_content_over_role, }; +pub use selected::selected_fill; #[cfg(test)] mod tests { diff --git a/crates/app-iced/src/components/interaction/selected.rs b/crates/app-iced/src/components/interaction/selected.rs new file mode 100644 index 0000000..e1d1f8f --- /dev/null +++ b/crates/app-iced/src/components/interaction/selected.rs @@ -0,0 +1,191 @@ +//! **选中容器的底色**:`SecondaryContainer` **叠上**状态层(M296)。 +//! +//! ## 为什么抽在这里(第三次法则) +//! +//! 「已选中的那一个」在壳里出现五次:菜单项、列表行、导航项药丸、导航轨(`shell/nav.rs`)、 +//! 账户头像位(`shell/nav/account.rs`)。M296 之前这五处各写一遍同一个形状: +//! +//! ```text +//! if selected { +//! layer_color(ui, state, OnSecondaryContainer, SecondaryContainer) +//! .or_else(|| Some(ui.color(SecondaryContainer))) +//! } +//! ``` +//! +//! 五份拷贝守的是**同一条 MD3 行为**,而第五份迟早会拿到 0.12 的 hover([`super`] 的模块文档 +//! 开头写的就是这件事)。塔 2026-11-13 的裁决把它钉成语义:选中容器上**再叠**状态层, +//! 两者不互斥——「选中了就不响应」是死控件,不是 Material。 +//! +//! 抽在 `components` 层而不是 `pages/settings` 的 `forced_button` / `resolve_state`:那两个服务的是 +//! 「强制态」(取证图怎么摆),这里服务的是「底色怎么算」,两回事,别合并。 +//! +//! ## 与未选中态叠层的分工 +//! +//! | 情形 | 入口 | 静息时返回 | +//! |---|---|---| +//! | **未**选中(容器透明,状态层就是它自己的底) | [`super::layer_color`] | `None`(不画底) | +//! | **已**选中(底是实色 `SecondaryContainer`) | [`selected_fill`] | 那个实色 | +//! +//! 两条不许合成一条:未选中的静息**没有底**,选中项的静息**有底**。合成一个入口就得在返回值里 +//! 塞「要不要画」,调用方又要各自决定 `None` 的含义——那正是这里想消灭的东西。 +//! +//! ## 走 `layer_color` 而不是 `overlay_color` +//! +//! M257 起状态层有过渡时间轴(`short1` 50ms)。`layer_color` 在**没有取样控件**时逐位等于 +//! `overlay_color`,所以取证图(`--force-state=*`)与旧基线一字不差;有取样控件时才有中间帧。 +//! 选中的底是实色,端点精确可判,插值只会落在两个不透明色之间(D27)。 + +use iced::Color; +use unlucky_design::color::Role; + +use crate::theme::Ui; + +use super::{layer_color, Interaction}; + +/// 选中容器的底色(**不透明**,`Color` 而不是 `Option`)。 +/// +/// - `state` 是当前交互态;静息([`Interaction::Enabled`])与过渡端点逐位等于 `ui.color(container)`; +/// - `content` 是叠在选中容器上的前景角色(MD3 的状态层用**内容色**,不是容器色); +/// - `container` 是选中容器角色。 +/// +/// 现存调用点的 `(content, container)` 一律是 +/// `(Role::OnSecondaryContainer, Role::SecondaryContainer)`——菜单项 / 列表行 / 导航项 / 版本选择行 +/// 都用「次强调容器表示当前项」这一套语汇(见 `components/navigation/list_item.rs` 的模块文档)。 +/// 参数留着是为了将来出现别的选中容器(例如 `TertiaryContainer` 的 chip 变体)时不再抄一遍函数体。 +/// +/// 禁用态**不**走这里:禁用换的是容器/内容色,不叠状态层([`super::disabled`])。 +/// 调用点必须先处理 `Interaction::Disabled`——那时选中项也不画满档 `SecondaryContainer` +/// (菜单项与列表行已有这条判据,见各自的 `tests`)。 +pub fn selected_fill(ui: &Ui, state: Interaction, content: Role, container: Role) -> Color { + // 静息 / 禁用 → `layer_color` 给 `None` → 兜底到实色容器(选中项的静息**有底**)。 + layer_color(ui, state, content, container).unwrap_or_else(|| ui.color(container)) +} + +#[cfg(test)] +mod tests { + use super::*; + use unlucky_design::color::{SchemeFlavor, StateLayer}; + use unlucky_design::typography::Platform; + + use crate::theme::Families; + + const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }; + + fn ui(flavor: SchemeFlavor) -> Ui { + Ui::with_platform(flavor, Platform::Windows, FAMILIES) + } + + /// 选中项的静息底 = `SecondaryContainer`,**逐位**等于角色求值。 + /// + /// 抓得住:有人把兜底写成别的角色(`SurfaceContainerHigh` 之类),或让静息也叠一层 + /// (`--force-state=rest` 的取证图会整片变色,判据 1 的 md5 立刻红)。 + #[test] + fn resting_selected_is_the_plain_container_role() { + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let ui = ui(flavor); + assert_eq!( + selected_fill( + &ui, + Interaction::Enabled, + Role::OnSecondaryContainer, + Role::SecondaryContainer + ), + ui.color(Role::SecondaryContainer), + "{flavor:?}:选中项静息必须是实色容器" + ); + } + } + + /// **选中 + hover / pressed / focus 三色两两不同,且都与静息不同**(M296 判据 2 的单测半边)。 + /// + /// 抓得住:把函数体改回「选中就不叠层」(直接 `ui.color(container)`)——四个断言一起红; + /// 把 hover 的 α 抄成 pressed 的 10%——`hover != pressed` 红。 + /// 抓不住:图上到底差几个色阶(那是 `DevDocs/artifacts/selstate` 的裁图判据)。 + #[test] + fn selected_reacts_to_hover_and_press() { + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let ui = ui(flavor); + let color = |state| { + selected_fill( + &ui, + state, + Role::OnSecondaryContainer, + Role::SecondaryContainer, + ) + }; + let rest = color(Interaction::Enabled); + let hover = color(Interaction::Hovered); + let pressed = color(Interaction::Pressed); + let focus = color(Interaction::Focused); + + assert_ne!(hover, rest, "{flavor:?}:选中项 hover 必须与静息不同"); + assert_ne!(pressed, rest, "{flavor:?}:选中项 pressed 必须与静息不同"); + assert_ne!(hover, pressed, "{flavor:?}:8% 与 10% 必须能区分"); + assert_eq!( + focus, pressed, + "{flavor:?}:focus 与 pressed 同 α(M3 口径),图上一样是**对的**" + ); + for (state, color) in [ + (Interaction::Enabled, rest), + (Interaction::Hovered, hover), + (Interaction::Pressed, pressed), + ] { + assert_eq!(color.a, 1.0, "{flavor:?}/{state:?}:结果必须不透明(D27)"); + } + } + } + + /// 叠的那一层**就是令牌求值**(改 α 会红,改组件抓不到)。 + #[test] + fn the_layer_is_the_token_blend_not_local_math() { + let ui = ui(SchemeFlavor::Light); + for (state, layer) in [ + (Interaction::Hovered, StateLayer::Hover), + (Interaction::Pressed, StateLayer::Pressed), + (Interaction::Focused, StateLayer::Focus), + ] { + assert_eq!( + selected_fill( + &ui, + state, + Role::OnSecondaryContainer, + Role::SecondaryContainer + ), + ui.state_over(layer, Role::OnSecondaryContainer, Role::SecondaryContainer), + "{state:?} 必须逐位等于 state_over 的令牌求值" + ); + } + } + + /// 没有取样控件时**逐位**等于 M257 之前的 `overlay_color(..).or(实色)` 写法。 + /// + /// 抓得住:有人把时间轴接进求值路径时顺手改了静息/终态的颜色(旧基线会漂)。 + /// 抓不住:过渡中间帧(`layer::tests` 的事)。 + #[test] + fn without_a_sampler_it_is_bit_identical_to_the_old_expression() { + let ui = ui(SchemeFlavor::Dark); + for state in Interaction::ALL { + let old = super::super::overlay_color( + &ui, + state, + Role::OnSecondaryContainer, + Role::SecondaryContainer, + ) + .unwrap_or_else(|| ui.color(Role::SecondaryContainer)); + assert_eq!( + selected_fill( + &ui, + state, + Role::OnSecondaryContainer, + Role::SecondaryContainer + ), + old, + "{state:?} 必须逐位退回旧写法" + ); + } + } +} diff --git a/crates/app-iced/src/components/navigation/drawer/item.rs b/crates/app-iced/src/components/navigation/drawer/item.rs index 4c818fb..075d69f 100644 --- a/crates/app-iced/src/components/navigation/drawer/item.rs +++ b/crates/app-iced/src/components/navigation/drawer/item.rs @@ -107,22 +107,21 @@ pub(super) struct ItemPalette { /// 药丸底色(**唯一**实现点;M257 起带时间轴)。 /// -/// 与 M256 之前的**唯一**差别是它经 [`interaction::layer_color`] 求值:没有取样控件时逐位等于 -/// 原来的 `overlay_color(...).or(...)`,有取样控件时按 `short1`(50ms) 的进度插值。 -/// 选中项的静息药丸是**实色**(`SecondaryContainer`),所以缺层时要兜底到那个角色; -/// 未选中项静息不画药丸,兜底就必须是 `None`——这两条不许合成一条。 +/// 选中项那一支经 [`interaction::selected_fill`]:静息 = 实色 `SecondaryContainer`, +/// hover / pressed 在它**上面**叠状态层(M296:选中与响应不互斥)。 +/// 未选中项静息不画药丸,所以那一支仍是 `Option` 语义的 [`interaction::layer_color`] +/// ——这两条不许合成一条。 pub(super) fn pill_color(ui: &Ui, base: Base, state: Interaction, selected: bool) -> Option { if state.is_disabled() { return None; } if selected { - interaction::layer_color( + Some(interaction::selected_fill( ui, state, Role::OnSecondaryContainer, Role::SecondaryContainer, - ) - .or_else(|| Some(ui.color(Role::SecondaryContainer))) + )) } else { interaction::layer_color(ui, state, Role::OnSurface, base.role()) } diff --git a/crates/app-iced/src/components/navigation/drawer/tests.rs b/crates/app-iced/src/components/navigation/drawer/tests.rs index 4044b28..c77ff99 100644 --- a/crates/app-iced/src/components/navigation/drawer/tests.rs +++ b/crates/app-iced/src/components/navigation/drawer/tests.rs @@ -23,6 +23,10 @@ fn ui() -> Ui { } /// §21.7:选中项图标/标签都是 `OnSecondaryContainer`,药丸 `SecondaryContainer`。 +/// +/// M296:药丸在选中时**也**吃 hover / pressed(实色容器上叠状态层),三态两两不同。 +/// 抓得住:选中分支写回「不叠层」(`hover == pressed == rest` 一起红); +/// 抓不住:图上差几个色阶(`DevDocs/artifacts/selstate` 的裁图判据)。 #[test] fn drawer_selected_uses_secondary_container() { let ui = ui(); @@ -33,6 +37,23 @@ fn drawer_selected_uses_secondary_container() { ); assert_eq!(palette.icon, ui.color(Role::OnSecondaryContainer)); assert_eq!(palette.label, ui.color(Role::OnSecondaryContainer)); + + let rest = pill_color(&ui, BASE, Interaction::Enabled, true); + let hover = pill_color(&ui, BASE, Interaction::Hovered, true); + let pressed = pill_color(&ui, BASE, Interaction::Pressed, true); + assert_ne!(hover, rest, "选中 + hover 必须与静息不同"); + assert_ne!(pressed, rest, "选中 + pressed 必须与静息不同"); + assert_ne!(hover, pressed, "8% 与 10% 必须能区分"); + assert_eq!( + hover, + Some(interaction::selected_fill( + &ui, + Interaction::Hovered, + Role::OnSecondaryContainer, + Role::SecondaryContainer + )), + "叠层必须经唯一入口求值" + ); } /// 12b §5.6:**rail** 的选中标签是 `Secondary`(与 drawer 不同,别串线)。 diff --git a/crates/app-iced/src/components/navigation/list_item.rs b/crates/app-iced/src/components/navigation/list_item.rs index 59b3876..ee6ae98 100644 --- a/crates/app-iced/src/components/navigation/list_item.rs +++ b/crates/app-iced/src/components/navigation/list_item.rs @@ -167,15 +167,13 @@ fn palette(ui: &Ui, base: Base, state: Interaction, selected: bool) -> ItemPalet } if selected { - let fill = interaction::overlay_color( - ui, - state, - Role::OnSecondaryContainer, - Role::SecondaryContainer, - ) - .unwrap_or_else(|| ui.color(Role::SecondaryContainer)); return ItemPalette { - fill: Some(fill), + fill: Some(interaction::selected_fill( + ui, + state, + Role::OnSecondaryContainer, + Role::SecondaryContainer, + )), headline: ui.color(Role::OnSecondaryContainer), supporting: ui.color(Role::OnSecondaryContainer), leading: ui.color(Role::OnSecondaryContainer), diff --git a/crates/app-iced/src/components/navigation/list_item/tests.rs b/crates/app-iced/src/components/navigation/list_item/tests.rs index 8009142..8a3a371 100644 --- a/crates/app-iced/src/components/navigation/list_item/tests.rs +++ b/crates/app-iced/src/components/navigation/list_item/tests.rs @@ -80,6 +80,39 @@ fn selected_row_is_secondary_container() { assert_eq!(palette.trailing, ui.color(Role::Primary), "4.8"); } +/// **选中行也吃 hover / pressed**(M296):底色三态两两不同,内容色不变。 +/// +/// 抓得住:有人把选中分支写回「选中就不叠层」(三色相等 ⇒ 三条 `assert_ne!` 一起红)。 +/// 抓不住:图上差几个色阶(`DevDocs/artifacts/selstate` 的裁图判据)。 +#[test] +fn a_selected_row_still_reacts_to_hover_and_press() { + let ui = ui(); + let pick = |state| palette(&ui, Base::CANVAS, state, true); + let (rest, hover, pressed) = ( + pick(Interaction::Enabled), + pick(Interaction::Hovered), + pick(Interaction::Pressed), + ); + assert_eq!(rest.fill, Some(ui.color(Role::SecondaryContainer))); + assert_ne!(hover.fill, rest.fill, "选中 + hover 必须与静息不同"); + assert_ne!(pressed.fill, rest.fill, "选中 + pressed 必须与静息不同"); + assert_ne!(hover.fill, pressed.fill, "8% 与 10% 必须能区分"); + assert_eq!( + hover.fill, + Some(interaction::selected_fill( + &ui, + Interaction::Hovered, + Role::OnSecondaryContainer, + Role::SecondaryContainer + )), + "叠层必须经唯一入口求值(不是本地算术)" + ); + for state in [hover, pressed] { + assert_eq!(state.headline, rest.headline, "叠层不许改文本色"); + assert_eq!(state.trailing, rest.trailing, "叠层不许改 trailing 色"); + } +} + /// 4.7:hover / pressed 只叠状态层,**内容色不变**。 #[test] fn interaction_states_keep_the_content_colors() {