Merge branch 'feat/m296-selstate-selected-items-get-hover-a'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-13 13:12:42 +08:00
commit fc8f5c6794
9 files changed
+391 -41

No files matched your search

+114 -17
View File
@@ -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<Color> {
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:?}:叠层必须经唯一入口求值"
);
}
}
}
@@ -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,
@@ -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, "禁用项不留满档选中底色");
@@ -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 {
@@ -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<Color>`)。
///
/// - `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:?} 必须逐位退回旧写法"
);
}
}
}
@@ -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<Color> {
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())
}
@@ -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 不同,别串线)。
@@ -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),
@@ -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() {