Merge branch 'feat/fix-disabled-control-compositing-base-at'
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
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-11 06:34:31 +08:00
commit 32951df3d6
51 files changed
+1845 -533

No files matched your search

@@ -2,6 +2,9 @@
//!
//! 单独成文件的原因:`mod.rs` 已经是「对外契约 + 变体角色表」,把绘制细节放一起会顶到 400 行红线;
//! 而且这条产线是**唯一**会把「令牌 → 像素」翻一次的地方,独立出来才好审。
//!
//! 禁用态的合成底([`Base`],M234)也从这里往下传:`Button::base(..)` → [`StyleSpec::base`] →
//! `disabled_button_colors` / `disabled_button_content`。装配侧没有第二个猜底色的地方。
use iced::widget::text::LineHeight;
use iced::widget::{button, text, Row};
@@ -14,9 +17,9 @@ use unlucky_design::shape::ShapeScale;
use crate::icons;
use crate::theme::Ui;
use super::super::interaction::{self, Interaction};
use super::super::interaction::{self, Base, Interaction};
use super::super::layout;
use super::{Geometry, Variant};
use super::{Button, Geometry, Variant};
/// 一个按钮样式要的全部输入(避免 `style_closure` 长出七个位置参数)。
#[derive(Debug, Clone, Copy)]
@@ -33,6 +36,12 @@ pub(crate) struct StyleSpec {
pub stroke: f32,
/// 圆角折算用的边长(药丸 = 可见高)。
pub radius_side: f32,
/// **脚下那一层的底**(M234):禁用态的 10% 容器与 38% 内容都叠在它上面。
///
/// 由构造点给(卡片 = `surfaces::elevated::BASE`、菜单项 = `inputs::menu::PANEL_BASE`、
/// 直接摆在画布上 = [`Base::CANVAS`])。M229 之前这里恒为 `Role::Surface`,
/// 卡片里的禁用按钮因此把 10% 叠在画布色上(M223 的「形丢失」)。
pub base: Base,
/// 强制态(gallery 取证);`None` = 跟随运行期的 `button::Status`。
///
/// **`Some(Enabled)` 与 `None` 不是一回事**:截图取证必须能强制「静息」,否则光标恰好停在
@@ -45,15 +54,11 @@ pub(crate) struct StyleSpec {
pub(super) fn assemble<'a, M: Clone + 'a>(
ui: &'a Ui,
variant: Variant,
spec: super::Button<'a, M>,
spec: Button<'a, M>,
) -> Element<'a, M> {
let geometry = Geometry::sized(ui, variant, spec.leading.is_some(), spec.size);
let (fill, content, border) = variant.roles();
let forced = spec.state.or(if spec.on_press.is_none() {
Some(Interaction::Disabled)
} else {
None
});
let (fill, content, _border) = variant.roles();
let forced = spec.forced();
// 按钮家族没有浮层级高程:L1(Elevated)只用容器色调差表达(rubric §7 不画阴影)
debug_assert!(
@@ -67,7 +72,14 @@ pub(super) fn assemble<'a, M: Clone + 'a>(
// 早先这里恒取 `ui.color(content)`,再被 `text(...).color(..)` 钉死,`Style.text_color`
// 里的 38% 永远下发不出去 —— 表现为「text / standard icon 的 enabled 格与 disabled 格
// 逐像素相同」(视觉 reviewer 实测 changed_px = 0;filled 禁用标签 1.23:1)。
let ink = content_color(ui, forced, content, fill, variant.disabled_content());
let ink = content_color(
ui,
spec.base,
forced,
content,
fill,
variant.disabled_content(),
);
// 标签排版跟尺寸档走:`ControlSize::label()` 已把「字号跟字级、字重固定 500」定死
// (XS/S = LabelLarge 14、M = BodyLarge 16、L = HeadlineSmall 24、XL = HeadlineLarge 32)。
@@ -106,18 +118,7 @@ pub(super) fn assemble<'a, M: Clone + 'a>(
.height(Length::Fill)
.align_y(iced::Alignment::Center);
let style = style_closure(
ui,
StyleSpec {
fill,
content,
border,
shape: variant.shape(),
stroke: geometry.stroke,
radius_side: geometry.height,
forced,
},
);
let style = style_closure(ui, text_style_spec(ui, variant, &spec));
let element = button(content_box)
.height(Length::Fixed(geometry.height))
@@ -130,6 +131,26 @@ pub(super) fn assemble<'a, M: Clone + 'a>(
}
}
/// `Button` → 样式描述:**装配的唯一接线点**(`assemble` 与单测都走这里)。
///
/// 抽出来的理由与 [`super::icon_style_spec`] 同源:`button::Style` 是闭包、`Element` 是黑盒,
/// 两者都不可内省,所以「禁用态的底到底传了哪一个」只能在**结构体**上断言。
/// 写死一个 `StyleSpec` 再喂给 `style_closure` 的测试证明不了接线(M229 复审 P1-1 踩过)。
pub(super) fn text_style_spec<M>(ui: &Ui, variant: Variant, spec: &Button<'_, M>) -> StyleSpec {
let (fill, content, border) = variant.roles();
let geometry = Geometry::sized(ui, variant, spec.leading.is_some(), spec.size);
StyleSpec {
fill,
content,
border,
shape: variant.shape(),
stroke: geometry.stroke,
radius_side: geometry.height,
base: spec.base,
forced: spec.forced(),
}
}
/// 内容色(文字与图标共用):禁用态走令牌的 38%,其余态就是角色色。
///
/// 单独成函数是为了让「内容色跟态走」这件事只有一处实现——`assemble`(文字按钮)与 `icon`
@@ -139,19 +160,20 @@ pub(super) fn assemble<'a, M: Clone + 'a>(
/// 其余是 `OnSurfaceVariant`,不能在这里统一写死。
pub(super) fn content_color(
ui: &Ui,
base: Base,
forced: Option<Interaction>,
content: Role,
fill: Option<Role>,
disabled_content: Role,
) -> iced::Color {
match forced {
// 有容器:38% 叠在**按钮家族 10% 容器**上(Filled / Tonal / Elevated)
// 有容器:38% 叠在**按钮家族 10% 容器**上(Filled / Tonal / Elevated),容器再叠在 `base` 上
Some(Interaction::Disabled) if fill.is_some() => {
interaction::disabled_button_content(ui, disabled_content, true)
interaction::disabled_button_content(ui, base, disabled_content, true)
}
// 无容器:38% 直接叠在 **surface** 上(Text / Outlined / standard IconButton)
// 无容器:38% 直接叠在 **base** 上(Text / Outlined / standard IconButton)
Some(Interaction::Disabled) => {
interaction::disabled_button_content(ui, disabled_content, false)
interaction::disabled_button_content(ui, base, disabled_content, false)
}
_ => ui.color(content),
}
@@ -168,28 +190,35 @@ pub(super) fn style_closure(
.forced
.unwrap_or_else(|| Interaction::from_status(status));
let (background, text_color) = if state.is_disabled() {
let (container, content) = interaction::disabled_button_colors(ui, Role::Surface);
// 透明容器在禁用态依然透明(官方只改内容透明度)
(spec.fill.map(|_| container), content)
} else {
// 有填充:状态层叠在**容器角色**上;透明容器:叠在表面色上(否则 hover 看不见)
let background = match spec.fill {
Some(role) => match state.layer() {
Some(_) => interaction::overlay_color(ui, state, spec.content, role)
.or(Some(ui.color(role))),
None => Some(ui.color(role)),
},
None => transparent_state_color(ui, state, spec.content),
};
(background, ui.color(spec.content))
// 禁用那一对颜色**只求值一次**:背景与描边用的是同一对值(早先调了两次)
let disabled = state
.is_disabled()
.then(|| interaction::disabled_button_colors(ui, spec.base));
let (background, text_color) = match disabled {
Some((container, content)) => {
// 透明容器在禁用态依然透明(官方只改内容透明度)
(spec.fill.map(|_| container), content)
}
None => {
// 有填充:状态层叠在**容器角色**上;透明容器:叠在表面色上(否则 hover 看不见)
let background = match spec.fill {
Some(role) => match state.layer() {
Some(_) => interaction::overlay_color(ui, state, spec.content, role)
.or(Some(ui.color(role))),
None => Some(ui.color(role)),
},
None => transparent_state_color(ui, spec.base, state, spec.content),
};
(background, ui.color(spec.content))
}
};
let border_color = if state.is_disabled() {
interaction::disabled_button_colors(ui, Role::Surface).0
} else {
spec.border
.map_or_else(|| ui.color(Role::OutlineVariant), |role| ui.color(role))
let border_color = match disabled {
Some((container, _content)) => container,
None => spec
.border
.map_or_else(|| ui.color(Role::OutlineVariant), |role| ui.color(role)),
};
button::Style {
@@ -217,14 +246,18 @@ pub(super) fn style_closure(
}
/// 透明容器的状态层:`Outlined` / `Text` / `StandardIcon` 没有填充,可见反馈只能来自
/// 叠在**内容角色**上的状态层——把它们当作「叠在表面色上」求值,得到不透明实色。
/// 叠在**内容角色**上的状态层——把它们当作「叠在 `base`(脚下那一层)上」求值,得到不透明实色。
///
/// 之所以单独一个函数:这是 M3 里唯一「容器不存在但状态层存在」的情形,
/// 三个变体共用一条口径,免得各自发挥。
///
/// M234 复审 P2-3:`base` 之前写死 `Role::Surface`,卡片里的 Text/Outlined 按钮抬起时
/// 状态层叠在画布色上(与禁用支的口径不一致)。
pub(super) fn transparent_state_color(
ui: &Ui,
base: Base,
state: Interaction,
content: Role,
) -> Option<iced::Color> {
interaction::overlay_color(ui, state, content, Role::Surface)
interaction::overlay_color(ui, state, content, base.role())
}
+32 -13
View File
@@ -35,7 +35,7 @@ use unlucky_design::typography::{TypeRole, TypeStyle, Weight};
use crate::icons;
use crate::theme::Ui;
use super::super::interaction::{self, Interaction};
use super::super::interaction::{self, Base, Interaction};
use super::super::layout;
/// 扩展态右侧内距(px)。规格卡只量到起始 28(`FabSize::pad_start_px`),右侧没有量;
@@ -86,6 +86,34 @@ impl<'a, M> Fab<'a, M> {
}
}
/// 禁用态的内容色:`OnPrimaryContainer` @38% 叠在 **FAB 自己的容器**(`PrimaryContainer`)上。
///
/// ## 为什么底是 FAB 自己的容器,不是画布(M234 复审 P2-2)
///
/// 五式按钮的禁用容器是「`OnSurface`@10% 叠在脚下那一层」——透明的那种甚至没有容器;所以它们的
/// 内容要叠 `0.10 容器 → 父底`。FAB 不一样:`Interaction::Disabled.layer()` 是 `None`,
/// 样式闭包走 `None => Some(ui.color(Role::PrimaryContainer))`,**禁用时背景仍是满档
/// `PrimaryContainer`**(官方 `FloatingActionButtonTokens` 也没有 DisabledContainerOpacity)。
/// 所以内容只能叠 `PrimaryContainer` 本身,`on_container = false`——那一层 10% 在 FAB 身上
/// 根本不存在,给了反而多算一层灰。
///
/// 修前传 `Base::CANVAS`:38% 的 `OnPrimaryContainer` 合在画布色上,出来是一块与 FAB 底无关的灰
/// (Silver 浅色实测是一块灰 `rgb(0.549,0.627,0.667)`),而不是「深色字压在淡蓝容器上」
/// 的应有结果(同板上正确值是 `rgb(0.329,0.561,0.737)`;数字见 artifacts/m234 的 README)。
///
/// `button()` 与样式闭包**共用这一个函数**:早先两处各写一遍,两次都传了同一个错值。
fn content_color(ui: &Ui, forced: Option<Interaction>) -> iced::Color {
match forced {
Some(Interaction::Disabled) => interaction::disabled_button_content(
ui,
Base::of(Role::PrimaryContainer),
Role::OnPrimaryContainer,
false,
),
_ => ui.color(Role::OnPrimaryContainer),
}
}
/// 画一颗 FAB。
pub fn fab<'a, M: Clone + 'a>(ui: &'a Ui, spec: Fab<'a, M>) -> Element<'a, M> {
let size = spec.size;
@@ -94,13 +122,8 @@ pub fn fab<'a, M: Clone + 'a>(ui: &'a Ui, spec: Fab<'a, M>) -> Element<'a, M> {
} else {
None
});
let ink = match forced {
// 禁用:38% 的内容色叠在容器上(与按钮族的容器档同一口径)
Some(Interaction::Disabled) => {
interaction::disabled_button_content(ui, Role::OnPrimaryContainer, true)
}
_ => ui.color(Role::OnPrimaryContainer),
};
// 内容色(含禁用档)只走 `content_color` 一处
let ink = content_color(ui, forced);
let glyph = layout::fixed(
size.icon_px(),
@@ -159,11 +182,7 @@ pub fn fab<'a, M: Clone + 'a>(ui: &'a Ui, spec: Fab<'a, M>) -> Element<'a, M> {
.or(Some(ui.color(Role::PrimaryContainer))),
None => Some(ui.color(Role::PrimaryContainer)),
};
let text_color = if state.is_disabled() {
interaction::disabled_button_content(ui, Role::OnPrimaryContainer, true)
} else {
ui.color(Role::OnPrimaryContainer)
};
let text_color = content_color(ui, Some(state));
button::Style {
background: background.map(Background::Color),
@@ -1,9 +1,29 @@
//! FAB 的几何断言(四档尺寸/圆角/图标/内距,全部对着规格卡原值)。
//!
//! 抓得住:档位与规格卡错位(例如把 Default 写成 48)、圆角被写成半高、扩展态内距悄悄改成 M3 的 16。
//! 抓不住:配色与状态层(那走 `interaction` 的既有测试)、以及图上好不好看(D30)。
//! 抓不住:图上好不好看(D30),以及 hover/pressed 的状态层(那走 `interaction` 的既有测试)。
//!
//! 配色只钉**一条**(禁用内容色,M234 复审 P2-2):FAB 没有禁用容器档,所以 38% 必须合在
//! 自己的 `PrimaryContainer` 上,而不是画布色。其余配色仍由 `interaction` 的测试覆盖。
use unlucky_design::color::{Role, SchemeFlavor};
use unlucky_design::inkcat::size::FabSize;
use unlucky_design::typography::Platform;
use super::super::super::interaction::Interaction;
use crate::theme::{to_iced, Families, Ui};
fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(
flavor,
Platform::Windows,
Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
},
)
}
#[test]
fn the_four_states_are_the_spec_sizes() {
@@ -52,3 +72,44 @@ fn the_extended_pad_end_is_a_named_constant() {
"左右对称:规格卡只量到起始 28,右侧取同值"
);
}
/// **M234 复审 P2-2 的判据**:禁用 FAB 的内容色必须合在**自己的容器**上。
///
/// 走的是真实现(`fab::content_color`,`button()` 与样式闭包共用它)。抓得住:底又改回画布
/// (那就是修前那块与 FAB 底无关的灰)、或有人把 `on_container` 打开(会多算一层
/// 10% 的 `OnSurface` 灰)。抓不住:容器本身该不该在禁用时保持满档 `PrimaryContainer`
/// (那是 M3 FAB token 的口径问题,不由本测试裁决)。
#[test]
fn disabled_fab_ink_composites_over_its_own_container() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let ui = ui(flavor);
let scheme = unlucky_design::color::BrandBoard::for_flavor(flavor).scheme();
// 期望值独立算:38% 的 OnPrimaryContainer 叠在满档 PrimaryContainer 上
let expected = to_iced(
scheme
.get(Role::OnPrimaryContainer)
.with_alpha(0.38)
.over(scheme.get(Role::PrimaryContainer)),
);
let actual = super::content_color(&ui, Some(Interaction::Disabled));
assert_eq!(
actual, expected,
"{flavor:?}:禁用 FAB 的内容色 = on-primary-container@38% 叠 PrimaryContainer"
);
// 反向:不许等于「叠画布」那一档(修前的错值)
let over_canvas = to_iced(
scheme
.get(Role::OnPrimaryContainer)
.with_alpha(0.38)
.over(scheme.get(Role::Surface)),
);
assert_ne!(actual, over_canvas, "{flavor:?}:又合到画布上了");
// 非禁用档不受影响
assert_eq!(
super::content_color(&ui, Some(Interaction::Enabled)),
ui.color(Role::OnPrimaryContainer)
);
}
}
@@ -0,0 +1,68 @@
//! IconButton 的四式(规格卡 `ICON BUTTONS / FILLED - TONAL - OUTLINED - PLAIN`)。
//!
//! M234 纯搬移:本文件原来只是 `buttons/mod.rs` 的第 166~225 行(`mod.rs` 已顶到 399 行,
//! 加 `.base(..)` 与 `icon_on` 之前必须先腾地方)。搬移**零行为改动**,`IconVariant` 的文档、
//! 角色表、A1 出处一字未改;对外路径由 `mod.rs` 的 `pub use` 保持为 `buttons::IconVariant`。
use unlucky_design::color::Role;
/// IconButton 的**四式**(规格卡 `ICON BUTTONS / FILLED - TONAL - OUTLINED - PLAIN`)。
///
/// 规格卡画了四颗:实心 / 色调 / 描边 / 纯图标。12b §2.5 只有前三式(Standard / Filled / Tonal),
/// M229 按规格卡把 Standard 拆成 **Plain**(纯图标)与 **Outlined**(1px 描边圆圈),
/// `Standard` 这个名字保留为 `Plain` 的别名语义,避免既有调用点全改。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum IconVariant {
/// 纯图标(规格卡 `PLAIN`):容器透明、图标 `OnSurfaceVariant`(各态图标色不变,12b 2.4)。
Plain,
/// 实心:`Primary` + `OnPrimary`。
Filled,
/// Tonal:`SecondaryContainer` + `OnSecondaryContainer`。
Tonal,
/// 描边:透明容器 + 1px `Outline`(规格卡第三颗,M229 新增)。
Outlined,
}
impl IconVariant {
/// 旧名字的别名:`Standard` = [`IconVariant::Plain`](既有调用点不必改语义)。
#[allow(non_upper_case_globals)]
pub const Standard: IconVariant = IconVariant::Plain;
/// 四式全序(gallery 顺序 = 规格卡从左到右)。
pub const ALL: [IconVariant; 4] = [
IconVariant::Filled,
IconVariant::Tonal,
IconVariant::Outlined,
IconVariant::Plain,
];
/// 稳定名字。
pub const fn name(self) -> &'static str {
match self {
IconVariant::Plain => "plain",
IconVariant::Filled => "filled",
IconVariant::Tonal => "tonal",
IconVariant::Outlined => "outlined",
}
}
/// **禁用态图标色**:四式都是 `OnSurface` @0.38 —— A1
/// (`StandardIconButtonTokens.DisabledColor` / `FilledIconButtonTokens.DisabledColor`)。
pub(crate) const fn disabled_content(self) -> Role {
Role::OnSurface
}
/// 色彩角色三元组(同 [`Variant::roles`] 的口径)。
pub(crate) const fn roles(self) -> (Option<Role>, Role, Option<Role>) {
match self {
IconVariant::Plain => (None, Role::OnSurfaceVariant, None),
IconVariant::Filled => (Some(Role::Primary), Role::OnPrimary, None),
IconVariant::Tonal => (
Some(Role::SecondaryContainer),
Role::OnSecondaryContainer,
None,
),
IconVariant::Outlined => (None, Role::OnSurfaceVariant, Some(Role::Outline)),
}
}
}
+50 -64
View File
@@ -53,15 +53,17 @@ use unlucky_design::shape::ShapeScale;
use crate::icons;
use crate::theme::Ui;
use super::interaction::Interaction;
use super::interaction::{Base, Interaction};
use super::layout;
mod assemble;
mod fab;
mod geometry;
mod icon_variant;
pub use fab::{fab, Fab};
pub use geometry::{Geometry, IconGeometry};
pub use icon_variant::IconVariant;
use assemble::{assemble, style_closure, StyleSpec};
@@ -163,67 +165,6 @@ impl Variant {
}
}
/// IconButton 的**四式**(规格卡 `ICON BUTTONS / FILLED - TONAL - OUTLINED - PLAIN`)。
///
/// 规格卡画了四颗:实心 / 色调 / 描边 / 纯图标。12b §2.5 只有前三式(Standard / Filled / Tonal),
/// M229 按规格卡把 Standard 拆成 **Plain**(纯图标)与 **Outlined**(1px 描边圆圈),
/// `Standard` 这个名字保留为 `Plain` 的别名语义,避免既有调用点全改。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum IconVariant {
/// 纯图标(规格卡 `PLAIN`):容器透明、图标 `OnSurfaceVariant`(各态图标色不变,12b 2.4)。
Plain,
/// 实心:`Primary` + `OnPrimary`。
Filled,
/// Tonal:`SecondaryContainer` + `OnSecondaryContainer`。
Tonal,
/// 描边:透明容器 + 1px `Outline`(规格卡第三颗,M229 新增)。
Outlined,
}
impl IconVariant {
/// 旧名字的别名:`Standard` = [`IconVariant::Plain`](既有调用点不必改语义)。
#[allow(non_upper_case_globals)]
pub const Standard: IconVariant = IconVariant::Plain;
/// 四式全序(gallery 顺序 = 规格卡从左到右)。
pub const ALL: [IconVariant; 4] = [
IconVariant::Filled,
IconVariant::Tonal,
IconVariant::Outlined,
IconVariant::Plain,
];
/// 稳定名字。
pub const fn name(self) -> &'static str {
match self {
IconVariant::Plain => "plain",
IconVariant::Filled => "filled",
IconVariant::Tonal => "tonal",
IconVariant::Outlined => "outlined",
}
}
/// **禁用态图标色**:四式都是 `OnSurface` @0.38 —— A1
/// (`StandardIconButtonTokens.DisabledColor` / `FilledIconButtonTokens.DisabledColor`)。
pub(crate) const fn disabled_content(self) -> Role {
Role::OnSurface
}
/// 色彩角色三元组(同 [`Variant::roles`] 的口径)。
pub(crate) const fn roles(self) -> (Option<Role>, Role, Option<Role>) {
match self {
IconVariant::Plain => (None, Role::OnSurfaceVariant, None),
IconVariant::Filled => (Some(Role::Primary), Role::OnPrimary, None),
IconVariant::Tonal => (
Some(Role::SecondaryContainer),
Role::OnSecondaryContainer,
None,
),
IconVariant::Outlined => (None, Role::OnSurfaceVariant, Some(Role::Outline)),
}
}
}
/// 一个按钮的装配描述(五式共用;**不持有任何状态所有权**)。
///
/// `on_press: Option<M>` 就是 M3 的 enabled/disabled 开关——`None` = 禁用,
@@ -238,6 +179,8 @@ pub struct Button<'a, M> {
state: Option<Interaction>,
/// 尺寸档(规格卡 `BUTTONS / XS - XL`)。产品默认 **S(40 高)**,与 M3 `control_height` 同值。
size: ControlSize,
/// **脚下那一层的底**(M234):禁用态的 10% 容器与 38% 内容叠在它上面。
base: Base,
}
impl<'a, M> Button<'a, M> {
@@ -250,6 +193,7 @@ impl<'a, M> Button<'a, M> {
trailing: None,
state: None,
size: ControlSize::S,
base: Base::CANVAS,
}
}
@@ -262,6 +206,7 @@ impl<'a, M> Button<'a, M> {
trailing: None,
state: None,
size: ControlSize::S,
base: Base::CANVAS,
}
}
@@ -291,6 +236,28 @@ impl<'a, M> Button<'a, M> {
self.state = Some(state);
self
}
/// **告诉按钮它现在踩在什么底上**(M234):禁用态的 10% 容器与 38% 内容都叠在这个底上。
///
/// 放进卡片/面板/浮层里**必须**给——不给就按 [`Base::CANVAS`] 合成,禁用态会与容器底
/// 脱节(M223 的「CR 1.05、形丢失」)。容器自己的 `BASE` 常量就是标准答案:
/// 卡片 = [`super::surfaces::elevated::BASE`]、菜单项 = `super::inputs::menu::PANEL_BASE`。
///
/// iced 没有环境上下文(样式闭包只拿得到 `&Theme`),所以这条信息只能显式传:
/// 这是「组件不猜宿主」的代价,也是 M229 那条「参数在、全传死值」的修法。
pub fn base(mut self, base: impl Into<Base>) -> Self {
self.base = base.into();
self
}
/// 视觉态:强制的先,否则「没有消息就是禁用」。
pub(super) fn forced(&self) -> Option<Interaction> {
self.state.or(if self.on_press.is_none() {
Some(Interaction::Disabled)
} else {
None
})
}
}
/// Filled 按钮(实心 `Primary`)。
@@ -337,6 +304,7 @@ pub(crate) fn icon_style_spec(
variant: IconVariant,
side: f32,
forced: Option<Interaction>,
base: Base,
) -> StyleSpec {
let (fill, content, border) = variant.roles();
StyleSpec {
@@ -348,6 +316,7 @@ pub(crate) fn icon_style_spec(
// M229 复审 P1-1:这里曾硬编码 `0.0`,于是 `Outlined` 与 `Plain` 逐像素相同。
stroke: icon_stroke(variant),
radius_side: side,
base,
forced,
}
}
@@ -360,6 +329,8 @@ pub(crate) fn icon_style_spec(
/// gallery 的样例因此统一用描边图标(`Download` + `ChevronRight`),并有单测钉住。
///
/// `on_press: Option<M>`(`None` = 禁用)与 `state` 分开给:前者是语义,后者是取证开关。
///
/// 这是 **画布版**(禁用底 = [`Base::CANVAS`]):放进卡片/面板里请用 [`icon_on`] 给真实父底。
pub fn icon<'a, M: Clone + 'a>(
ui: &'a Ui,
variant: IconVariant,
@@ -367,6 +338,20 @@ pub fn icon<'a, M: Clone + 'a>(
on_press: Option<M>,
state: Option<Interaction>,
) -> Element<'a, M> {
icon_on(ui, variant, kind, on_press, state, Base::CANVAS)
}
/// 带**父底**的 IconButton(M234):禁用态的 10% 容器与 38% 内容叠在 `base` 上。
#[allow(clippy::too_many_arguments)]
pub fn icon_on<'a, M: Clone + 'a>(
ui: &'a Ui,
variant: IconVariant,
kind: IconKind,
on_press: Option<M>,
state: Option<Interaction>,
base: impl Into<Base>,
) -> Element<'a, M> {
let base = base.into();
let geometry = IconGeometry::of(ui);
let (fill, content, _) = variant.roles();
let forced = state.or(if on_press.is_some() {
@@ -380,11 +365,12 @@ pub fn icon<'a, M: Clone + 'a>(
// 这里把盒子做成整个 40×40(而不是 24×24):`button::layout` 把内容放左上角、不居中,
// 所以「居中」这件事必须由容器自己写死,别指望 button。
// 内容色跟态走(禁用 = on-surface@38%),见 `assemble::content_color` 的说明
let glyph = assemble::content_color(ui, forced, content, fill, variant.disabled_content());
let glyph =
assemble::content_color(ui, base, forced, content, fill, variant.disabled_content());
let icon_box =
layout::centered_fixed(geometry.side, geometry.side, icons::view::<M>(kind, glyph));
let style = style_closure(ui, icon_style_spec(variant, geometry.side, forced));
let style = style_closure(ui, icon_style_spec(variant, geometry.side, forced, base));
let element = button(icon_box)
.width(Length::Fixed(geometry.side))
@@ -11,7 +11,7 @@ use unlucky_design::elevation::Elevation;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use super::super::interaction::{self, Interaction};
use super::super::interaction::{self, Base, Interaction};
use super::assemble::StyleSpec;
use super::{style_closure, Geometry, IconGeometry, IconVariant, Variant};
use crate::theme::{Families, Ui, STROKE_PX};
@@ -122,6 +122,7 @@ fn icon_button_geometry_matches_the_reference() {
);
}
mod base;
mod icon;
/// 视觉 reviewer P2-3:同一控件里不许混用填充字形与描边字形(线宽语汇会打架)。
@@ -255,6 +256,7 @@ fn spec_of(variant: Variant, forced: Option<Interaction>) -> StyleSpec {
content,
border,
shape: variant.shape(),
base: Base::CANVAS,
stroke: Geometry::of(&light(), variant, false).stroke,
radius_side: Geometry::of(&light(), variant, false).height,
forced,
@@ -283,8 +285,7 @@ fn outlined_style_draws_hairline_border_and_full_radius() {
assert_ne!(hovered.background, idle.background);
let disabled = style(&ui.theme(), button::Status::Disabled);
let (container, content) =
interaction::disabled_button_colors(&ui, unlucky_design::color::Role::Surface);
let (container, content) = interaction::disabled_button_colors(&ui, Base::CANVAS);
assert_eq!(disabled.text_color, content, "禁用内容 = 令牌 38%");
assert_eq!(
disabled.border.color, container,
@@ -0,0 +1,75 @@
//! 禁用态**合成底**的接线判据(M234;从 `buttons/tests.rs` 拆出,守 400 行硬线)。
//!
//! 判据走真装配路径(`Button::base` → `text_style_spec` → `style_closure`),
//! 不是「自己拼一个 `StyleSpec` 再喂进去」——后者证明不了接线(M229 复审 P1-1 的教训)。
use iced::widget::button;
use iced::Background;
use unlucky_design::color::Role;
use unlucky_design::color::SchemeFlavor;
use super::super::Button;
use super::{light, style_closure, Variant};
use crate::app::components::interaction::Base;
use crate::app::components::surfaces;
use crate::theme::to_iced;
/// **M234 的接线判据**:禁用按钮的容器色必须在**它被告诉的那层底**上合成。
///
/// 走的是**真装配路径**:`Button::base(..)` → `text_style_spec`(接线)→ `style_closure`
/// (求值)。所以这条红了只有两种原因:① `assemble` 没把 `spec.base` 传下去;
/// ② `disabled_button_colors` 内部又写死了画布色。
///
/// 抓得住:卡片/面板里的禁用按钮叠错底(M223 的「CR 1.05、形丢失」就是这么来的)。
/// 抓不住:调用点**忘了**调 `.base(..)`(编译期不报错,默认 `Base::CANVAS`)——那一半由
/// `gallery` 的像素 probe(`ULL_GALLERY_SECTION=disabledbase`)与卡片场景守。
#[test]
fn a_disabled_button_composites_over_the_base_it_is_given() {
use unlucky_design::color::BrandBoard;
let ui = light();
let card = surfaces::elevated::BASE;
let spec = super::super::assemble::text_style_spec(
&ui,
Variant::Filled,
&Button::<u8>::disabled("卡片里"),
);
assert_eq!(
spec.base,
Base::CANVAS,
"不调 `.base(..)` 时是画布底(默认值,语义写在 `Base::CANVAS` 的文档里)"
);
let card_spec = super::super::assemble::text_style_spec(
&ui,
Variant::Filled,
&Button::<u8>::disabled("卡片里").base(card),
);
assert_eq!(
card_spec.base, card,
"接线:`Button::base` 必须原样进 `StyleSpec`"
);
let card_style = style_closure(&ui, card_spec)(&ui.theme(), button::Status::Disabled);
let canvas_style = style_closure(&ui, spec)(&ui.theme(), button::Status::Disabled);
// 期望值独立算(写死 0.10 + 直接取色板角色),不调被测入口。
let scheme = BrandBoard::for_flavor(SchemeFlavor::Light).scheme();
let expected_card = to_iced(
scheme
.get(Role::OnSurface)
.with_alpha(0.10)
.over(scheme.get(Role::SurfaceContainer)),
);
assert_eq!(
card_style.background,
Some(Background::Color(expected_card)),
"卡片里的禁用 Filled 按钮容器 = on-surface@10% 叠 **SurfaceContainer**"
);
assert_ne!(
card_style.background, canvas_style.background,
"卡片底与画布底的合成结果必须不同(叠在画布上就是 M234 要修的那条)"
);
}
@@ -38,7 +38,7 @@ fn only_the_outlined_icon_variant_draws_a_ring() {
);
// **接线断言**:`icon()` 真正塞进 `style_closure` 的那个结构体(`icon_style_spec`)。
// 只测 `icon_stroke()` 抓不住「调用点写死 0」——第一版就是这样漏的(变异测试证明过)。
let spec = icon_style_spec(variant, 40.0, None);
let spec = icon_style_spec(variant, 40.0, None, Base::CANVAS);
assert_eq!(
spec.stroke,
icon_stroke(variant),
@@ -13,14 +13,14 @@
//! ## 禁用是状态不是构造器
//!
//! 照 `selection/chip.rs` 修好的模式:`on_press: Option<M>`,`None` = 禁用;文字色降到
//! `OnSurface@38%`,标签本身**不删**。禁用色的**底**必须是这两个动作件自己的容器角色
//! (`InverseSurface` / `SurfaceContainerHigh`)——`interaction::disabled_content_over_role`
//! 只支持「叠在 Surface / 叠在 12% 容器上」两档,拿它合成会得到「38% 深字叠深底」(复审 P2-4)。
//! `OnSurface@38%`,标签本身**不删**。禁用色的**底**是这两个动作件自己的容器角色
//! (`InverseSurface` / `SurfaceContainerHigh`)——M234 起
//! [`interaction::disabled_content_over_role`] 收任意底([`interaction::Base`]),所以这里
//! **直接传容器角色**,不再自己写一遍 α 合成(M234 之前那段本地合成会得到「38% 深字叠深底」,复审 P2-4)。
use iced::widget::{button, text};
use iced::{Background, Border, Element, Length, Padding, Shadow};
use unlucky_design::color::state::DISABLED_CONTENT_ALPHA;
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::icons::IconKind;
@@ -28,28 +28,29 @@ use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::icons;
use crate::theme::{to_iced, Ui};
use crate::theme::Ui;
use super::super::interaction::{self, Interaction};
use super::super::layout;
/// 禁用态内容色:`OnSurface@38%` 叠在**真实容器色**上(复审 P2-4)。
/// 禁用态内容色:`OnSurface@38%` 叠在**真实容器色**上。
///
/// `interaction::disabled_content_over_role` 的 `on_container` 只有「叠在 Surface / 叠在
/// 12% 容器上」两档,而这两个动作件的底是 `InverseSurface`(Snackbar)与
/// `SurfaceContainerHigh`(Dialog)——直接调它会得到「38% 深字叠深底」。
/// 这里按同一令牌口径([`DISABLED_CONTENT_ALPHA`])在本文件合成一次,底换成真实容器角色。
/// 这两个动作件的底是 `InverseSurface`(Snackbar)与 `SurfaceContainerHigh`(Dialog),
/// M234 之后 `interaction::disabled_content_over_role` 收任意 [`interaction::Base`],所以
/// 这里只把容器角色包成 `Base` 交给它——α(0.38)与叠法仍由 `interaction::disabled` 一处给,
/// 本文件零 α 字面量、也没有第二份合成实现。
fn content_color(ui: &Ui, role: Role, container_role: Role, disabled: bool) -> iced::Color {
if !disabled {
return ui.color(role);
}
// 取 `Ui` 的当前方案(品牌板 / 取色方案),不重算 baseline(复审 F1)。
let scheme = ui.scheme();
to_iced(
scheme
.get(Role::OnSurface)
.with_alpha(DISABLED_CONTENT_ALPHA)
.over(scheme.get(container_role)),
// M234:`disabled_content_over_role` 现在收**任意**底(`Base`),所以这里的本地合成可以删掉
// ——底就是这两个动作件自己的容器角色(Snackbar = InverseSurface / Dialog = SurfaceContainerHigh),
// α 与叠法仍由 `interaction::disabled` 那一处给(本文件零 α 字面量)。
interaction::disabled_content_over_role(
ui,
interaction::Base::of(container_role),
Role::OnSurface,
false,
)
}
@@ -17,10 +17,9 @@
//! 1. **数值来自哪里**:颜色/字级/圆角/内距走令牌(`Role` / `TypeRole` / `ShapeScale` / `Space`),
//! 进度厚度、Snackbar 宽高、Dialog 宽、空态块这四组令牌层没有 → 登记 **G17**([`geometry`])。
//! 2. **状态层只有一处**:hover/pressed 的叠色一律经 [`interaction::overlay_color`];
//! 禁用内容默认经 [`interaction::disabled_content_over_role`],但**动作件是个例外**——
//! `action.rs` 的 `content_color()` 把 38% 内容色叠在它**自己的容器角色**上
//! (`InverseSurface` / `SurfaceContainerHigh`),因为 `interaction` 那两档只认
//! 「叠在 Surface / 12% 容器上」;
//! 禁用内容一律经 [`interaction::disabled_content_over_role`],**底由容器给**
//! ([`interaction::Base`]):`action.rs` 传的是这两个动作件自己的容器角色
//! (`InverseSurface` / `SurfaceContainerHigh`),其余组件传 `Base::CANVAS`;
//! 禁用是**状态**(`on_press: Option<M>` 为 `None`),不是另一个构造器(M77 之前 Chip 踩过)。
//! 3. **精确尺寸经 `layout`**:图标/几何图元一律 `layout::fixed` / `layout::centered_fixed`(Q9),
//! 居中一律父级 `align_x/align_y`(Q8,不许 `center_*`)。
@@ -11,7 +11,7 @@ use unlucky_design::typography::Platform;
use crate::theme::{to_iced, Families, Ui};
use super::super::interaction::{self, Interaction};
use super::super::interaction::{self, Base, Interaction};
use super::dialog::scrim;
use super::panel::{DialogGeometry, EmptyStateGeometry, SnackbarGeometry};
use super::progress::Progress;
@@ -235,7 +235,8 @@ fn snackbar_state_layers_come_from_the_interaction_module() {
);
// 禁用内容走 token 口径(38%),不是「变灰一点」
let disabled = interaction::disabled_content_over_role(&ui, Role::OnSurface, false);
let disabled =
interaction::disabled_content_over_role(&ui, Base::CANVAS, Role::OnSurface, false);
assert_ne!(disabled, ui.color(Role::OnSurface));
assert_eq!(disabled.a, 1.0);
}
@@ -0,0 +1,210 @@
//! 禁用态的**合成底**(M234):同一批禁用控件摆在三种底上,一眼看出「叠的是哪一层」。
//!
//! ## 这张图回答什么
//!
//! M223 记的「CR 1.05、形丢失」是这么来的:`disabled.rs` 的入口收一个 `Role` 当合成底,
//! 而**所有调用点都传死 `Role::Surface`**——卡片(`SurfaceContainer`)与面板
//! (`SurfaceContainerLow`)里的禁用控件于是把 10%(按钮族)/ 12%(面状控件族)那一层叠在
//! **画布色**上,与它真正的底差一层。
//!
//! 这里把三列**并排**摆出来,每列一块板、板底就是控件真踩的那层底:
//!
//! ```text
//! canvas Surface ← 页面底色
//! card SurfaceContainer ← `surfaces::elevated::card` 的底(`elevated::BASE`)
//! panel SurfaceContainerLow ← Card 层(取景板 / 面板)
//! ```
//!
//! 每列都是**同一族控件**:Filled/Outlined 禁用按钮、Filled 禁用字段、禁用开关与 chip、
//! 禁用搜索条、禁用列表项、禁用滑块、禁用 tab。控件拿的都是这一列的 [`Base`],
//! 所以列与列之间的差别只有「底」。
//!
//! ## 判据(数值在 `probe/bases.rs`)
//!
//! 像素判据判的是**精确值**:卡列里的禁用按钮容器必须等于 `on-surface@10% 叠卡片底`,
//! 且**不等于**画布底那一档。注意量级:Ink/Silver 板上 `Surface` 与 `SurfaceContainer`
//! 只差个位数 RGB,10% 之后差 **≈1/255**——肉眼看不出来,所以这张图的用处是**取证**,
//! 不是「看出区别」。真判据在 `interaction::disabled::tests`(逐值)与 `probe/bases.rs`(像素)。
//!
//! ```text
//! ULL_GALLERY_SECTION=disabledbase $EXE --software-renderer --screen=gallery \
//! --screenshot out.png --screenshot-theme=light
//! ULL_GALLERY_SECTION=disabledbase ULL_GALLERY_SHOT=out.png \
//! cargo test -p unlucky-app-iced gallery::probe
//! ```
use iced::widget::{column, container, row, text};
use iced::{Background, Border, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
use super::super::buttons::{self, Button};
use super::super::inputs::{self, Field, SearchBar, Slider};
use super::super::interaction::{self, Base, Interaction};
use super::super::navigation::{self, ListItem, Tab, Tabs};
use super::super::selection::{self, CheckValue, Checkbox, Chip, Radio, Switch};
use super::{heading, DemoMessage};
/// 一列的底(**板画什么,控件就叠什么**)。
const COLUMNS: [(&str, Base); 3] = [
("canvas · Surface", Base::CANVAS),
(
"card · SurfaceContainer",
super::super::surfaces::elevated::BASE,
),
(
"panel · SurfaceContainerLow",
Base::of(Role::SurfaceContainerLow),
),
];
/// 板宽:3 × 360 + 2 × 8 间距 = 1096 < 1180 − 2×24 页边距。
const PLATE_WIDTH: f32 = 360.0;
/// `#RRGGBB`(图上的色值要能直接和 probe 的期望值对上)。
fn hex(color: iced::Color) -> String {
let channel = |v: f32| (v * 255.0).round() as u8;
format!(
"#{:02X}{:02X}{:02X}",
channel(color.r),
channel(color.g),
channel(color.b)
)
}
/// 整段(标题 + 三块并排的板)。
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let note = ui.style(TypeRole::BodySmall);
let columns = COLUMNS
.iter()
.copied()
.map(|(title, base)| plate(ui, title, base))
.collect::<Vec<_>>();
column![
heading(
ui,
"disabled · 合成底 = 所在容器的底(M234)",
"10%(按钮族)/ 12%(面状控件族)/ 38%(内容)全部预合成成实色;\
三列控件相同,只有**脚下的底**不同",
),
row(columns).spacing(ui.space(Space::X2)),
text(
"三列的控件完全相同,只有脚下的底不同。禁用容器保留 90% 的底差(10% 只叠 on-surface),所以列间差别 = 底差的九成(浅色 Silver 11/255、深色 Ink 6/255)。这张图是取证;判据在 `interaction::disabled::tests`(逐值)与 `gallery::probe` 的 disabledbase 段(精确像素)",
)
.size(note.size_px)
.font(ui.font_of(note))
.color(ui.color(Role::OnSurfaceVariant)),
]
.spacing(ui.space(Space::X2))
.into()
}
/// 一块板:**底色就是这一列的 `base`**,控件全部把同一个 `base` 传进 `.base(..)`。
///
/// 板上写着这一列**算出来的**色值(同一入口:`disabled_button_colors` / `disabled_colors`):
/// 三列只差 ≈1/255,肉眼判不了,图上的数字是给 reviewer 与 probe 对账用的。
fn plate<'a>(ui: &'a Ui, title: &'a str, base: Base) -> Element<'a, DemoMessage> {
let caption = ui.style(TypeRole::BodySmall);
let label = text(title)
.size(caption.size_px)
.font(ui.font_of(caption))
.color(ui.color(Role::OnSurface));
let numbers = text(format!(
"底 {} · 禁用按钮(10%) {} · 禁用字段(12%) {}",
hex(ui.color(base.role())),
hex(interaction::disabled_button_colors(ui, base).0),
hex(interaction::disabled_colors(ui, base).0),
))
.size(caption.size_px)
.font(ui.font_of(caption))
.color(ui.color(Role::OnSurfaceVariant));
container(
column![
column![label, numbers].spacing(ui.space(Space::X1)),
column_body(ui, base)
]
.spacing(ui.space(Space::X2)),
)
.width(Length::Fixed(PLATE_WIDTH))
.padding(Padding::from(ui.space(Space::X2)))
.style(move |_theme| container::Style {
// 板底 = 这一列的 base;描边只为让 `canvas` 列也有边界(画布色与页面同色)。
background: Some(Background::Color(ui.color(base.role()))),
border: Border {
color: ui.color(Role::OutlineVariant),
width: crate::theme::STROKE_PX,
radius: ui.radius_dp(unlucky_design::shape::ShapeScale::Medium),
},
..container::Style::default()
})
.into()
}
/// 一列控件:**同族不同底**,每条都显式传 `base`。
fn column_body<'a>(ui: &'a Ui, base: Base) -> Element<'a, DemoMessage> {
let gap = ui.space(Space::X2);
column![
row![
buttons::filled(ui, Button::disabled("保存").base(base)),
buttons::outlined(ui, Button::disabled("取消").base(base)),
]
.spacing(gap),
inputs::text_field(
ui,
Field::new("")
.label("名称")
.placeholder("禁用字段")
.state(Interaction::Disabled)
.base(base),
|_| DemoMessage::Pressed("field"),
),
row![
selection::checkbox(
ui,
Checkbox::disabled("勾选", CheckValue::Unchecked).base(base),
),
selection::radio(ui, Radio::disabled("单选", false).base(base)),
selection::switch(ui, Switch::disabled("开关", false).base(base)),
selection::chip(
ui,
Chip::filter("标签", false, DemoMessage::Pressed("chip"))
.disabled()
.base(base),
),
]
.spacing(gap),
inputs::search_bar(
ui,
SearchBar::new("")
.placeholder("搜索")
.state(Interaction::Disabled)
.base(base),
|_| DemoMessage::Pressed("search"),
),
navigation::list_item(
ui,
ListItem::new("列表项", DemoMessage::Pressed("item"))
.disabled()
.base(base),
),
inputs::slider(
Slider::new(ui, 0.45)
.state(Interaction::Disabled)
.base(base),
),
navigation::tabs(
ui,
Tabs::primary()
.tab(Tab::new("标签页", DemoMessage::Pressed("tab")).state(Interaction::Disabled),)
.base(base),
),
]
.spacing(gap)
.into()
}
+10 -1
View File
@@ -80,6 +80,7 @@ use super::interaction::Interaction;
use super::layout;
mod buttons;
mod disabledbase;
mod feedback;
mod inputs;
mod navigation;
@@ -97,6 +98,7 @@ pub const SECTION_ENV: &str = "ULL_GALLERY_SECTION";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Sections {
buttons: bool,
disabledbase: bool,
sizes: bool,
divider: bool,
selection: bool,
@@ -109,6 +111,7 @@ impl Sections {
/// 七段全渲染(默认)。
const ALL: Self = Self {
buttons: true,
disabledbase: true,
sizes: true,
divider: true,
selection: true,
@@ -121,6 +124,7 @@ impl Sections {
fn only(name: &str) -> Option<Self> {
let mut sections = Self {
buttons: false,
disabledbase: false,
sizes: false,
divider: false,
selection: false,
@@ -130,6 +134,7 @@ impl Sections {
};
match name {
"buttons" => sections.buttons = true,
"disabledbase" => sections.disabledbase = true,
"sizes" => sections.sizes = true,
"divider" => sections.divider = true,
"selection" => sections.selection = true,
@@ -156,7 +161,7 @@ impl Sections {
return sections;
}
eprintln!(
"[gallery] {SECTION_ENV}={raw:?} 不是已知段(all|buttons|sizes|divider|selection|inputs|feedback|navigation),已回落 all"
"[gallery] {SECTION_ENV}={raw:?} 不是已知段(all|buttons|disabledbase|sizes|divider|selection|inputs|feedback|navigation),已回落 all"
);
Self::ALL
}
@@ -166,6 +171,7 @@ impl Sections {
let mut names = Vec::new();
for (on, name) in [
(self.buttons, "buttons"),
(self.disabledbase, "disabledbase"),
(self.sizes, "sizes"),
(self.divider, "divider"),
(self.selection, "selection"),
@@ -244,6 +250,9 @@ pub fn view<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
if picked.buttons {
sections = sections.push(buttons::section(ui));
}
if picked.disabledbase {
sections = sections.push(disabledbase::section(ui));
}
if picked.sizes {
sections = sections.push(sizes::section(ui));
}
@@ -36,10 +36,11 @@ use unlucky_design::typography::Platform;
use crate::theme::{Families, Ui};
use crate::app::components::interaction;
use crate::app::components::interaction::{self, Base};
use super::scan::{matches, nearer, rgb, Shot};
mod bases;
mod rows;
const FAMILIES: Families = Families {
@@ -146,14 +147,19 @@ fn gallery_geometry_matches_the_tokens() {
.trim()
.to_ascii_lowercase();
assert!(
section == "buttons" || section == "divider",
"这份 probe 只支持 `ULL_GALLERY_SECTION=buttons`(按钮矩阵)或 `divider`(分隔线);当前 {section:?}。`all` 不行:整页比视口高,分隔线段与按钮矩阵不可能同屏。"
section == "buttons" || section == "divider" || section == "disabledbase",
"这份 probe 只支持 `ULL_GALLERY_SECTION=buttons`(按钮矩阵)/ `divider`(分隔线)/ `disabledbase`(禁用态合成底);当前 {section:?}。`all` 不行:整页比视口高,分隔线段与按钮矩阵不可能同屏。"
);
// 分隔线那段是**独立出口**:`divider` 段的 sheet 里没有按钮矩阵,判据也不同。
if section == "divider" {
check_divider(&shot, &ui);
return;
}
// 禁用态合成底(M234):三块板各按自己的底合成,判据在 `probe/bases.rs`。
if section == "disabledbase" {
bases::check(&shot, &ui, flavor);
return;
}
// ---- 12b 1.1 / 1.2:实心按钮 40 高、药丸圆角 20 ------------------------------
let primary = shot.bbox_of(rgb(ui.color(Role::Primary)));
@@ -309,7 +315,7 @@ fn gallery_geometry_matches_the_tokens() {
//
// **按钮族**:容器 10%(官方 `*ButtonTokens.DisabledContainerOpacity = 0.1f`)、内容 38%。
// M86 之前这里和 chip 共用同一个 12% 常量,所以断言与实现「一起错」、永远同源——拆族后才对得上。
let (disabled_container, _) = interaction::disabled_button_colors(&ui, Role::Surface);
let (disabled_container, _) = interaction::disabled_button_colors(&ui, Base::CANVAS);
assert!(
shot.bbox_of(rgb(disabled_container))
.iter()
@@ -0,0 +1,222 @@
//! `ULL_GALLERY_SECTION=disabledbase` 的像素判据:**禁用态叠的是哪一层底**(M234)。
//!
//! ## 为什么必须用「精确匹配」而不是扫描工具的 `matches`(±10)
//!
//! Ink / Silver 板上 `Surface` 与 `SurfaceContainer` 只差个位数 RGB,10% 之后差 **≈1/255**:
//! `scan::matches` 的 ±10 容差会把「叠在画布上」与「叠在卡片上」判成同一个颜色——
//! 那样这条判据就永远绿。所以本文件自己数**逐位相等**的像素([`count_exact`]),
//! 并且只数**板内**区域,避开页面底色。
//!
//! ## 量什么
//!
//! 1. 卡列 / 面板列里:禁用按钮容器(按钮族 10%)与禁用字段容器(面状族 12%)的像素值
//! = 各自「@10%/12% 叠**该列板底**」;**不许**出现「叠画布底」那一档;
//! 2. 画布列(卡列左侧、同样的纵向范围)里反过来:出现画布档、且不是卡/面板档;
//! 3. 三档的期望值两两不同(哪一对相等,说明 `Base` 被丢了或两族 α 合流了)。
//!
//! ## 抓得住 / 抓不住
//!
//! - 抓得住:调用点把底传错(叠画布)或没传(默认 `Base::CANVAS`)——卡列里会出现画布档的像素
//! 而缺卡片档;两族 α 合流、入口内部写死 `Role::Surface`,同样在这里红。
//! - 抓不住:① 容器**自己**的画法(板底 vs 卡组件的 `BASE` 是否一致——那条由
//! `surfaces::elevated::tests` 的「卡底必须是 SurfaceContainer」守);
//! ② 页面里的真实卡片(pages/ 的调用点尚未接 `.base(..)`,见报告「没验的部分」);
//! ③ GPU(wgpu) 档:本判据读的是 tiny-skia(`--software-renderer`) 出的图;
//! ④ 差 <1/255 的第三层底(例如 `SurfaceContainer` 与 `SurfaceContainerHigh` 的 10%)——
//! 那种情况本文件会退化成「两档相等」,第 3 条断言会红,**不会静默放过**。
use unlucky_design::color::Role;
use super::super::scan::{rgb, Shot};
use crate::app::components::interaction::{self, Base};
use crate::app::components::surfaces;
use crate::theme::Ui;
/// 板子的最小尺寸(判据只认「一整列」,不许把某个控件误当成板)。
const MIN_PLATE_W: usize = 300;
const MIN_PLATE_H: usize = 260;
/// 禁用按钮容器在板内的最少像素(Filled 药丸 40 高 × ≥58 宽,扣掉字形与圆角)。
const MIN_BUTTON_PX: usize = 300;
/// 禁用字段容器的最少像素(板宽 ~336 × 56,扣掉标签与图标)。
const MIN_FIELD_PX: usize = 3000;
/// 「正确档位 : 错误档位」的最小倍数。
///
/// 为什么不干脆要求「错误档位 = 0 px」:**抗锯齿会造出中间色**——板底与控件容器之间那一圈
/// 1px 过渡像素会顺路撞上「另一档」的精确值(实测卡片列里 226/226/227 有 58 px,全是边缘)。
/// 真实回归的量级完全不同(整颗按钮 ≈3000 px、整块字段 ≈18000 px),所以判据写成**倍数**:
/// 正确档位必须比错误档位多一个数量级。
const STRAY_RATIO: usize = 10;
/// 逐位相等的像素计数器(`scan::matches` 的 ±10 容差在这里不够用,见模块文档)。
fn count_exact(shot: &Shot, color: [u8; 3], x0: usize, x1: usize, y0: usize, y1: usize) -> usize {
(y0..y1.min(shot.height))
.map(|y| {
(x0..x1.min(shot.width))
.filter(|x| shot.at(*x, y) == color)
.count()
})
.sum()
}
/// 一块板(`left..right × top..bottom`,闭区间;`x1`/`y1` 是开区间上界)。
type Rect = (usize, usize, usize, usize);
/// 按**逐位相等**的颜色切出竖列:每列 = 一段连续且够宽的 x,y 取该段内的最小/最大行。
///
/// 为什么不用 `scan::bbox_of`(±10 容差):本板的三档底(`Surface` / `SurfaceContainer` /
/// `SurfaceContainerLow`)两两只差 5~11 个 RGB 单位,落在容差里会被并成一块
/// (实测把卡片列与面板列判成同一块)。
fn exact_columns(shot: &Shot, color: [u8; 3]) -> Vec<Rect> {
let mut columns = Vec::new();
let mut current: Option<(usize, usize, usize, usize)> = None;
for x in 0..shot.width {
let rows: Vec<usize> = (0..shot.height)
.filter(|y| shot.at(x, *y) == color)
.collect();
match (&mut current, rows.first()) {
(None, Some(top)) => {
current = Some((x, x + 1, *top, rows[rows.len() - 1] + 1));
}
(Some(col), Some(top)) => {
col.1 = x + 1;
col.2 = col.2.min(*top);
col.3 = col.3.max(rows[rows.len() - 1] + 1);
}
(Some(col), None) => {
if col.1 - col.0 >= MIN_PLATE_W && col.3 - col.2 >= MIN_PLATE_H {
columns.push(*col);
}
current = None;
}
(None, None) => {}
}
}
if let Some(col) = current {
if col.1 - col.0 >= MIN_PLATE_W && col.3 - col.2 >= MIN_PLATE_H {
columns.push(col);
}
}
columns
}
fn find_plate(shot: &Shot, role: Role, ui: &Ui, name: &str) -> Rect {
let color = rgb(ui.color(role));
let mut columns = exact_columns(shot, color);
assert_eq!(
columns.len(),
1,
"{name} 列({role:?} 底,{color:?})应当恰好是 1 列,实测 {}: 板子尺寸判据 MIN {MIN_PLATE_W}×{MIN_PLATE_H}",
columns.len()
);
columns.remove(0)
}
/// 判据入口(`probe.rs` 的 `disabledbase` 分支调它)。
pub(super) fn check(shot: &Shot, ui: &Ui, flavor: unlucky_design::color::SchemeFlavor) {
let card = Base::of(Role::SurfaceContainer);
let panel = Base::of(Role::SurfaceContainerLow);
let card_rect = find_plate(shot, Role::SurfaceContainer, ui, "card");
let panel_rect = find_plate(shot, Role::SurfaceContainerLow, ui, "panel");
// 画布列:三列等宽等距(`disabledbase::PLATE_WIDTH` 固定 360、间距 8),所以它 =
// 卡列左侧同样宽的一列。画布板的底与页面同色(`Surface`),**没法**靠颜色切出来。
let step = panel_rect.0 - card_rect.0;
assert!(
step > MIN_PLATE_W,
"两列间距 {step} 不合理(列宽 {MIN_PLATE_W} 起)—— 布局变了,画布列的镜像推算不再成立"
);
let canvas_rect = (0, card_rect.0, card_rect.2, card_rect.3);
let canvas_left = card_rect.0.saturating_sub(step);
let button = |base: Base| rgb(interaction::disabled_button_colors(ui, base).0);
let field = |base: Base| rgb(interaction::disabled_colors(ui, base).0);
// 三档必须两两不同(哪一对相等就说明底被丢了 / 两族 α 合流了)
let (canvas_button, canvas_field) = (button(Base::CANVAS), field(Base::CANVAS));
let (card_button, card_field) = (button(card), field(card));
let (panel_button, panel_field) = (button(panel), field(panel));
assert_ne!(
canvas_button, card_button,
"{flavor:?}:画布底与卡片底算出来的按钮容器相同 —— 底被丢了"
);
assert_ne!(
canvas_field, card_field,
"{flavor:?}:画布底与卡片底算出来的字段容器相同"
);
assert_ne!(
card_button, panel_button,
"{flavor:?}:卡片底与面板底必须可分"
);
assert_ne!(
card_field, panel_field,
"{flavor:?}:卡片底与面板底必须可分"
);
assert_eq!(
surfaces::elevated::BASE,
card,
"卡片列用的底就该是 `surfaces::elevated::BASE`(容器导出的那个常量)"
);
for (rect, base, wanted_button, wanted_field, name) in [
(card_rect, card, card_button, card_field, "card"),
(panel_rect, panel, panel_button, panel_field, "panel"),
] {
let (x0, x1, y0, y1) = rect;
// 两个计数**分开命名**:它们是不同量测对象,拿字段数去比按钮 stray 会让断言恒绿
// (M234 复审 P2-1 的现场)。
let button_hits = count_exact(shot, wanted_button, x0, x1, y0, y1);
assert!(
button_hits >= MIN_BUTTON_PX,
"{name} 列里「叠 {:?}」的禁用按钮容器只有 {button_hits} px(应 ≥{MIN_BUTTON_PX}): 这一列的禁用控件没把真实父底传下去",
base.role()
);
let field_hits = count_exact(shot, wanted_field, x0, x1, y0, y1);
assert!(
field_hits >= MIN_FIELD_PX,
"{name} 列里「叠 {:?}」的禁用字段容器只有 {field_hits} px(应 ≥{MIN_FIELD_PX})",
base.role()
);
// 反向 ①:**按钮**档 —— 这一列里「叠画布」的按钮容器不许和「叠该列底」的比肩。
// 这条抓的是「只修了一半」:同列里一部分按钮仍走画布底时,正确档与错误档的像素量接近。
let stray_button = count_exact(shot, canvas_button, x0, x1, y0, y1);
assert!(
button_hits > stray_button * STRAY_RATIO,
"{name} 列里「叠 {:?}」的禁用按钮容器 {button_hits} px,而「叠画布」的**按钮容器** 有 {stray_button} px(要求前者 ≥ 后者 ×{STRAY_RATIO})—— 这一列的按钮仍在按 Role::Surface 合成",
base.role()
);
// 反向 ②:**字段**档(同理,各用各的计数)
let stray_field = count_exact(shot, canvas_field, x0, x1, y0, y1);
assert!(
field_hits > stray_field * STRAY_RATIO,
"{name} 列里「叠 {:?}」的禁用字段容器 {field_hits} px,而「叠画布」的**字段容器** 有 {stray_field} px(要求前者 ≥ 后者 ×{STRAY_RATIO})—— 这一列的字段仍在按 Role::Surface 合成",
base.role()
);
assert!(
base.role() != Role::Surface,
"{name} 列不该是画布底(判据自己写错了)"
);
}
// 画布列:反过来必须出现画布档
let (_, cx1, cy0, cy1) = canvas_rect;
let cx0 = canvas_left;
let canvas_hits = count_exact(shot, canvas_button, cx0, cx1, cy0, cy1);
assert!(
canvas_hits >= MIN_BUTTON_PX,
"画布列里没有「叠画布」的禁用按钮容器 —— 对照组不成立"
);
let canvas_card_hits = count_exact(shot, card_button, cx0, cx1, cy0, cy1);
assert!(
canvas_hits > canvas_card_hits * STRAY_RATIO,
"画布列里「叠画布」的禁用按钮容器 {canvas_hits} px,而「叠卡片底」的有 {canvas_card_hits} px —— 画布列的控件被传了卡片底"
);
eprintln!(
"[probe] OK section=disabledbase flavor={flavor:?}:\
画布列 x<{} · 卡片列左缘 x={}..{} · 面板列左缘 x={}..{};\
按钮容器色 canvas {:?}({canvas_hits} px)⇒ 三列各按自己的底合成\
(两个数字都是列左缘横坐标,不是像素计数)",
card_rect.0, card_rect.0, card_rect.1, panel_rect.0, panel_rect.1, canvas_button
);
}
@@ -7,6 +7,7 @@ use unlucky_design::color::Role;
use super::super::scan::{rgb, Shot};
use crate::app::components::interaction;
use crate::app::components::interaction::Base;
use crate::theme::Ui;
/// 矩阵行距(40 高按钮 + 8 间距)。
@@ -38,7 +39,7 @@ pub(super) fn assert_matrix_rows(shot: &Shot, ui: &Ui, label_top: usize, label_l
let y1 = y0 + ROW_HEIGHT;
// **与实现同源**:按钮族的内容色一律走 `disabled_button_content`(38% 叠在 10% 底上)。
// 用面状控件族的 12% 底会让断言比实现宽一档,差的 1/255 会放跑「容器对内容错」。
let ink = interaction::disabled_button_content(ui, role, on_container);
let ink = interaction::disabled_button_content(ui, Base::CANVAS, role, on_container);
let count = shot.count_in(rgb(ink), column_from, shot.width, y0, y1);
// 阈值 **30**:最窄的一行是只有 4 个字符的 `text` 变体(实测 38 px 墨迹,M229 复审后
// 按钮内距 24→20 让标签左移,余量只剩 2 px)。角色接错时会掉到个位数甚至 0。
+20 -103
View File
@@ -22,7 +22,6 @@ use iced::widget::text::LineHeight;
use iced::widget::{column, container, row, text, text_input};
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
@@ -30,10 +29,16 @@ use unlucky_design::typography::TypeRole;
use crate::icons;
use crate::theme::Ui;
use super::super::interaction::{self, Interaction};
use super::super::interaction::{Base, Interaction};
#[cfg(test)]
use super::super::interaction;
use super::super::layout;
use super::geometry::FieldGeometry;
mod paint;
use paint::palette;
/// 两式容器(12b 9.3 / 9.4 的差别:Filled 有容器色 + 底部指示线;Outlined 只有描边)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FieldVariant {
@@ -70,6 +75,8 @@ pub struct Field<'a> {
state: Option<Interaction>,
width: Length,
secure: bool,
/// 脚下那一层的底(M234,禁用态合成用)。
base: Base,
}
impl<'a> Field<'a> {
@@ -87,9 +94,19 @@ impl<'a> Field<'a> {
state: None,
width: Length::Fill,
secure: false,
base: Base::CANVAS,
}
}
/// **告诉字段它踩在什么底上**(M234):禁用态的 12% 容器 / 描边与 38% 内容都叠在这个底上。
///
/// 放进卡片/面板里必须给(卡片 = `surfaces::elevated::BASE`);不给就按画布合成,
/// 禁用字段会与容器底脱节(M223 的「形丢失」)。
pub fn base(mut self, base: impl Into<Base>) -> Self {
self.base = base.into();
self
}
/// 选式(Filled / Outlined)。
pub fn variant(mut self, variant: FieldVariant) -> Self {
self.variant = variant;
@@ -151,106 +168,6 @@ impl<'a> Field<'a> {
}
}
/// 一个字段在某一态下的全套颜色(9.4 / 9.7 / 9.8 的落点)。
#[derive(Debug, Clone, Copy, PartialEq)]
struct Palette {
/// 容器底色;`None` = 透明(Outlined)。
fill: Option<Color>,
/// 描边/指示线色。
line: Color,
/// 描边/指示线宽(1 或 2)。
stroke: f32,
/// 标签(`BodySmall`)。
label: Color,
/// 输入文本。
input: Color,
/// 前后图标。
icon: Color,
/// 辅助文本。
supporting: Color,
/// 占位文本。
placeholder: Color,
}
/// 解析某一态的颜色(**唯一**做角色映射的地方)。
fn palette(ui: &Ui, variant: FieldVariant, state: Interaction, error: bool) -> Palette {
let geometry = FieldGeometry::of(ui, true, None);
if state.is_disabled() {
// 9.8:内容一律 OnSurface@38%;Filled 容器叠 12%(官方 4%,令牌无 4% 档 → D10);
// Outlined 描边正好是官方 12%。
let container = interaction::disabled_colors(ui, Role::Surface).0;
let ink = interaction::disabled_content_over_role(
ui,
Role::OnSurface,
matches!(variant, FieldVariant::Filled),
);
return Palette {
fill: match variant {
FieldVariant::Filled => Some(container),
FieldVariant::Outlined => None,
},
line: match variant {
FieldVariant::Filled => ink,
FieldVariant::Outlined => container,
},
stroke: geometry.stroke,
label: ink,
input: ink,
icon: ink,
supporting: ink,
placeholder: ink,
};
}
let focused = matches!(state, Interaction::Focused);
let hovered = matches!(state, Interaction::Hovered | Interaction::Pressed);
let (line, stroke) = if error {
(ui.color(Role::Error), geometry.focus_stroke)
} else if focused {
(ui.color(Role::Primary), geometry.focus_stroke)
} else if hovered {
(ui.color(Role::OnSurface), geometry.stroke)
} else {
(
match variant {
FieldVariant::Filled => ui.color(Role::OnSurfaceVariant),
FieldVariant::Outlined => ui.color(Role::Outline),
},
geometry.stroke,
)
};
Palette {
fill: match variant {
FieldVariant::Filled => Some(ui.color(Role::SurfaceContainerHighest)),
FieldVariant::Outlined => None,
},
line,
stroke,
label: if error {
ui.color(Role::Error)
} else if focused {
ui.color(Role::Primary)
} else {
ui.color(Role::OnSurfaceVariant)
},
input: ui.color(Role::OnSurface),
icon: if error {
ui.color(Role::Error)
} else {
ui.color(Role::OnSurfaceVariant)
},
supporting: if error {
ui.color(Role::Error)
} else {
ui.color(Role::OnSurfaceVariant)
},
placeholder: ui.color(Role::OnSurfaceVariant),
}
}
/// 一个文本框(Filled / Outlined 由 [`Field::variant`] 选)。
///
/// 受控:值由外部持有,输入经 `on_input` 回传;`state` 强制成 `Disabled` 时输入被摘掉
@@ -262,7 +179,7 @@ pub fn text_field<'a, M: Clone + 'a>(
) -> Element<'a, M> {
let geometry = FieldGeometry::of(ui, field.label.is_some(), field.leading);
let state = field.state.unwrap_or(Interaction::Enabled);
let colors = palette(ui, field.variant, state, field.error);
let colors = palette(ui, field.base, field.variant, state, field.error);
let input_style = ui.style(TypeRole::BodyLarge);
let label_style = ui.style(TypeRole::BodySmall);
@@ -0,0 +1,125 @@
//! 文本框的颜色求值(`research/12b` §9.4 / §9.7 / §9.8 的落点)。
//!
//! M234 纯搬移:本文件原来是 `inputs/field.rs` 的 `Palette` + `palette()`(`field.rs` 已顶到
//! 398 行,加 `.base(..)` 之前必须腾地方)。搬移只做了一件事以外的零改动——`palette()` 多收
//! 一个 [`Base`](禁用态的合成底,M234),其余角色映射一字未改。
use iced::Color;
use unlucky_design::color::Role;
use crate::theme::Ui;
use super::super::super::interaction::{self, Base, Interaction};
use super::super::geometry::FieldGeometry;
use super::FieldVariant;
/// 一个字段在某一态下的全套颜色(9.4 / 9.7 / 9.8 的落点)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub(super) struct Palette {
/// 容器底色;`None` = 透明(Outlined)。
pub(super) fill: Option<Color>,
/// 描边/指示线色。
pub(super) line: Color,
/// 描边/指示线宽(1 或 2)。
pub(super) stroke: f32,
/// 标签(`BodySmall`)。
pub(super) label: Color,
/// 输入文本。
pub(super) input: Color,
/// 前后图标。
pub(super) icon: Color,
/// 辅助文本。
pub(super) supporting: Color,
/// 占位文本。
pub(super) placeholder: Color,
}
/// 解析某一态的颜色(**唯一**做角色映射的地方)。
///
/// `base` 是字段**脚下那一层的底**(M234):禁用态的 12% 容器与 38% 内容都叠在它上面
/// (Filled 容器 / Outlined 描边 / 全部标签与文本)。调用点给真实父底,不写死 `Role::Surface`。
pub(super) fn palette(
ui: &Ui,
base: Base,
variant: FieldVariant,
state: Interaction,
error: bool,
) -> Palette {
let geometry = FieldGeometry::of(ui, true, None);
if state.is_disabled() {
// 9.8:内容一律 OnSurface@38%;Filled 容器叠 12%(官方 4%,令牌无 4% 档 → D10);
// Outlined 描边正好是官方 12%。
let container = interaction::disabled_colors(ui, base).0;
let ink = interaction::disabled_content_over_role(
ui,
base,
Role::OnSurface,
matches!(variant, FieldVariant::Filled),
);
return Palette {
fill: match variant {
FieldVariant::Filled => Some(container),
FieldVariant::Outlined => None,
},
line: match variant {
FieldVariant::Filled => ink,
FieldVariant::Outlined => container,
},
stroke: geometry.stroke,
label: ink,
input: ink,
icon: ink,
supporting: ink,
placeholder: ink,
};
}
let focused = matches!(state, Interaction::Focused);
let hovered = matches!(state, Interaction::Hovered | Interaction::Pressed);
let (line, stroke) = if error {
(ui.color(Role::Error), geometry.focus_stroke)
} else if focused {
(ui.color(Role::Primary), geometry.focus_stroke)
} else if hovered {
(ui.color(Role::OnSurface), geometry.stroke)
} else {
(
match variant {
FieldVariant::Filled => ui.color(Role::OnSurfaceVariant),
FieldVariant::Outlined => ui.color(Role::Outline),
},
geometry.stroke,
)
};
Palette {
fill: match variant {
FieldVariant::Filled => Some(ui.color(Role::SurfaceContainerHighest)),
FieldVariant::Outlined => None,
},
line,
stroke,
label: if error {
ui.color(Role::Error)
} else if focused {
ui.color(Role::Primary)
} else {
ui.color(Role::OnSurfaceVariant)
},
input: ui.color(Role::OnSurface),
icon: if error {
ui.color(Role::Error)
} else {
ui.color(Role::OnSurfaceVariant)
},
supporting: if error {
ui.color(Role::Error)
} else {
ui.color(Role::OnSurfaceVariant)
},
placeholder: ui.color(Role::OnSurfaceVariant),
}
}
@@ -1,6 +1,7 @@
//! field 的单测(从 `field.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。
use super::*;
use unlucky_design::color::Role;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
@@ -44,23 +45,47 @@ fn every_variant_and_state_builds() {
#[test]
fn outline_colors_and_widths_follow_the_state() {
let ui = ui(SchemeFlavor::Light);
let rest = palette(&ui, FieldVariant::Outlined, Interaction::Enabled, false);
let rest = palette(
&ui,
Base::CANVAS,
FieldVariant::Outlined,
Interaction::Enabled,
false,
);
assert_eq!(rest.line, ui.color(Role::Outline));
assert_eq!(rest.stroke, 1.0);
assert_eq!(rest.fill, None, "Outlined 无容器色");
let hover = palette(&ui, FieldVariant::Outlined, Interaction::Hovered, false);
let hover = palette(
&ui,
Base::CANVAS,
FieldVariant::Outlined,
Interaction::Hovered,
false,
);
assert_eq!(hover.line, ui.color(Role::OnSurface));
assert_eq!(hover.stroke, 1.0);
let focus = palette(&ui, FieldVariant::Outlined, Interaction::Focused, false);
let focus = palette(
&ui,
Base::CANVAS,
FieldVariant::Outlined,
Interaction::Focused,
false,
);
assert_eq!(focus.line, ui.color(Role::Primary));
assert_eq!(focus.stroke, 2.0, "FocusOutlineWidth = 2dp");
assert_eq!(focus.label, ui.color(Role::Primary), "FocusLabelColor");
// A1 没有 Pressed token:按 hover 处理(D11),不是漏做
assert_eq!(
palette(&ui, FieldVariant::Outlined, Interaction::Pressed, false),
palette(
&ui,
Base::CANVAS,
FieldVariant::Outlined,
Interaction::Pressed,
false
),
hover
);
}
@@ -69,7 +94,13 @@ fn outline_colors_and_widths_follow_the_state() {
#[test]
fn error_beats_focus_and_paints_everything_error() {
let ui = ui(SchemeFlavor::Light);
let error = palette(&ui, FieldVariant::Outlined, Interaction::Focused, true);
let error = palette(
&ui,
Base::CANVAS,
FieldVariant::Outlined,
Interaction::Focused,
true,
);
assert_eq!(error.line, ui.color(Role::Error));
assert_eq!(error.label, ui.color(Role::Error));
assert_eq!(error.supporting, ui.color(Role::Error));
@@ -82,21 +113,33 @@ fn error_beats_focus_and_paints_everything_error() {
fn disabled_uses_the_token_alphas() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let ui = ui(flavor);
let container = interaction::disabled_colors(&ui, Role::Surface).0;
let container = interaction::disabled_colors(&ui, Base::CANVAS).0;
let filled = palette(&ui, FieldVariant::Filled, Interaction::Disabled, false);
let filled = palette(
&ui,
Base::CANVAS,
FieldVariant::Filled,
Interaction::Disabled,
false,
);
assert_eq!(filled.fill, Some(container));
assert_eq!(
filled.input,
interaction::disabled_content_over_role(&ui, Role::OnSurface, true)
interaction::disabled_content_over_role(&ui, Base::CANVAS, Role::OnSurface, true)
);
let outlined = palette(&ui, FieldVariant::Outlined, Interaction::Disabled, false);
let outlined = palette(
&ui,
Base::CANVAS,
FieldVariant::Outlined,
Interaction::Disabled,
false,
);
assert_eq!(outlined.fill, None);
assert_eq!(outlined.line, container, "DisabledOutlineOpacity = 0.12");
assert_eq!(
outlined.input,
interaction::disabled_content_over_role(&ui, Role::OnSurface, false)
interaction::disabled_content_over_role(&ui, Base::CANVAS, Role::OnSurface, false)
);
assert_ne!(outlined.input, ui.color(Role::OnSurface), "禁用不许满档");
}
@@ -107,7 +150,14 @@ fn disabled_uses_the_token_alphas() {
fn filled_carries_the_container_role() {
let ui = ui(SchemeFlavor::Light);
assert_eq!(
palette(&ui, FieldVariant::Filled, Interaction::Enabled, false).fill,
palette(
&ui,
Base::CANVAS,
FieldVariant::Filled,
Interaction::Enabled,
false
)
.fill,
Some(ui.color(Role::SurfaceContainerHighest)),
"FilledTextFieldTokens.ContainerColor"
);
+22 -10
View File
@@ -42,7 +42,7 @@ use crate::icons;
use crate::theme::Ui;
use super::super::divider;
use super::super::interaction::{self, Interaction};
use super::super::interaction::{self, Base, Interaction};
use super::super::layout;
use super::geometry::MenuGeometry;
@@ -171,10 +171,10 @@ struct ItemPalette {
}
/// 解析一项的颜色:选中优先,其余按五态叠状态层(`interaction::overlay_color`)。
fn item_palette(ui: &Ui, state: Interaction, selected: bool) -> ItemPalette {
fn item_palette(ui: &Ui, base: Base, state: Interaction, selected: bool) -> ItemPalette {
if state.is_disabled() {
// 禁用:内容 38%(无容器时叠在表面色上;菜单项本身透明)
let ink = interaction::disabled_content_over_role(ui, Role::OnSurface, false);
// 禁用:内容 38%,叠在**菜单面板的底**上(项本身透明,透出来的就是面板色)
let ink = interaction::disabled_content_over_role(ui, base, Role::OnSurface, false);
return ItemPalette {
fill: None,
ink,
@@ -201,14 +201,20 @@ fn item_palette(ui: &Ui, state: Interaction, selected: bool) -> ItemPalette {
}
ItemPalette {
// 状态层叠在**菜单容器色**上(SurfaceContainer),得到不透明结果(D27)
fill: interaction::overlay_color(ui, state, Role::OnSurface, Role::SurfaceContainer),
// 状态层叠在**菜单容器色**上(`PANEL_BASE` = SurfaceContainer),得到不透明结果(D27)
fill: interaction::overlay_color(ui, state, Role::OnSurface, base.role()),
ink: ui.color(Role::OnSurface),
icon: ui.color(Role::OnSecondaryContainer),
shape: ShapeScale::Small,
}
}
/// 菜单弹出层的底(**面板画这个色,项就叠在它上面**)。
///
/// 8.2 的 `MenuTokens.ContainerColor = SurfaceContainer`:面板用 `ui.color(PANEL_BASE.role())` 画底,
/// 项的禁用态(38% 内容)与状态层拿同一个 [`Base`] 合成——两边同源,改色不会只改一半(M234)。
pub const PANEL_BASE: Base = Base::of(Role::SurfaceContainer);
/// 菜单弹出层(`SurfaceContainer` + L2 阴影)。
pub fn menu<'a, M: Clone + 'a>(ui: &'a Ui, menu: Menu<'a, M>) -> Element<'a, M> {
let geometry = MenuGeometry::of(ui);
@@ -220,13 +226,13 @@ pub fn menu<'a, M: Clone + 'a>(ui: &'a Ui, menu: Menu<'a, M>) -> Element<'a, M>
"MenuTokens.ContainerElevation"
);
let shadow_spec = elevation.shadow();
let panel = ui.color(Role::SurfaceContainer);
let panel = ui.color(PANEL_BASE.role());
let shape = geometry.shape;
let mut rows = column![].width(Length::Fill);
for entry in menu.entries {
rows = rows.push(match entry {
Entry::Item(item) => item_row(ui, item, geometry),
Entry::Item(item) => item_row(ui, PANEL_BASE, item, geometry),
// 分组分隔线:复用 `divider::full`(一像素线 + OutlineVariant),不自己画
Entry::Divider => divider::full::<M>(ui),
});
@@ -256,6 +262,7 @@ pub fn menu<'a, M: Clone + 'a>(ui: &'a Ui, menu: Menu<'a, M>) -> Element<'a, M>
/// 一行菜单项(用 `button` 承载命中与四态,几何与颜色自绘)。
fn item_row<'a, M: Clone + 'a>(
ui: &'a Ui,
base: Base,
item: MenuItem<'a, M>,
geometry: MenuGeometry,
) -> Element<'a, M> {
@@ -265,7 +272,12 @@ fn item_row<'a, M: Clone + 'a>(
None
});
// 选中项的背景也要跟态走:禁用时不许留满档 SecondaryContainer
let colors = item_palette(ui, forced.unwrap_or(Interaction::Enabled), item.selected);
let colors = item_palette(
ui,
base,
forced.unwrap_or(Interaction::Enabled),
item.selected,
);
let body = ui.style(TypeRole::BodyMedium);
let label = text(item.label)
@@ -308,7 +320,7 @@ fn item_row<'a, M: Clone + 'a>(
let selected = item.selected;
let style = move |_theme: &iced::Theme, status: button::Status| {
let state = forced.unwrap_or_else(|| Interaction::from_status(status));
let colors = item_palette(ui, state, selected);
let colors = item_palette(ui, base, state, selected);
button::Style {
background: colors.fill.map(Background::Color),
text_color: colors.ink,
@@ -55,13 +55,13 @@ fn container_uses_surface_container_and_level2_shadow() {
#[test]
fn selected_and_disabled_items_use_the_right_roles() {
let ui = ui(SchemeFlavor::Light);
let selected = item_palette(&ui, Interaction::Enabled, true);
let selected = item_palette(&ui, PANEL_BASE, Interaction::Enabled, true);
assert_eq!(selected.fill, Some(ui.color(Role::SecondaryContainer)));
assert_eq!(selected.ink, ui.color(Role::OnSecondaryContainer));
assert_eq!(selected.icon, ui.color(Role::OnSecondaryContainer));
// 选中 + hover:状态层叠在 SecondaryContainer 上(不是满档底色)
let hovered = item_palette(&ui, Interaction::Hovered, true);
let hovered = item_palette(&ui, PANEL_BASE, Interaction::Hovered, true);
assert_ne!(hovered.fill, selected.fill);
assert_eq!(
hovered.fill,
@@ -73,11 +73,11 @@ fn selected_and_disabled_items_use_the_right_roles() {
)
);
let disabled = item_palette(&ui, Interaction::Disabled, true);
let disabled = item_palette(&ui, PANEL_BASE, Interaction::Disabled, true);
assert_eq!(disabled.fill, None, "禁用项不留满档选中底色");
assert_eq!(
disabled.ink,
interaction::disabled_content_over_role(&ui, Role::OnSurface, false)
interaction::disabled_content_over_role(&ui, PANEL_BASE, Role::OnSurface, false)
);
assert_ne!(disabled.ink, ui.color(Role::OnSurface));
}
@@ -86,14 +86,14 @@ fn selected_and_disabled_items_use_the_right_roles() {
#[test]
fn item_states_blend_on_the_menu_container() {
let ui = ui(SchemeFlavor::Light);
let rest = item_palette(&ui, Interaction::Enabled, false);
let rest = item_palette(&ui, PANEL_BASE, Interaction::Enabled, false);
assert_eq!(rest.fill, None, "静息不叠色(菜单底色透出来)");
for state in [
Interaction::Hovered,
Interaction::Pressed,
Interaction::Focused,
] {
let fill = item_palette(&ui, state, false)
let fill = item_palette(&ui, PANEL_BASE, state, false)
.fill
.expect("可交互态必须给底色");
assert_eq!(fill.a, 1.0, "结果必须不透明(D27)");
@@ -38,7 +38,7 @@ use unlucky_design::typography::TypeRole;
use crate::icons;
use crate::theme::{Ui, STROKE_PX};
use super::super::interaction::{self, Interaction};
use super::super::interaction::{self, Base, Interaction};
use super::super::layout;
use super::geometry::{SEARCH_MAX_WIDTH, SEARCH_MIN_WIDTH, SEARCH_VIEW_MIN_HEIGHT};
@@ -52,6 +52,8 @@ pub struct SearchBar<'a> {
trailing: Option<IconKind>,
state: Option<Interaction>,
width: Length,
/// 脚下那一层的底(M234,禁用态合成用)。
base: Base,
}
impl<'a> SearchBar<'a> {
@@ -63,6 +65,7 @@ impl<'a> SearchBar<'a> {
trailing: Some(IconKind::Filter),
state: None,
width: Length::Fill,
base: Base::CANVAS,
}
}
@@ -83,12 +86,22 @@ impl<'a> SearchBar<'a> {
self.width = width;
self
}
/// **告诉药丸它踩在什么底上**(M234):禁用态那 12% 的容器叠在这个底上。
///
/// 药丸自己的容器色是 `SurfaceContainerHighest`,但**禁用它不画那个色**——禁用画的是
/// 「12% 叠父底」,所以这里要的是父底(卡片 = `surfaces::elevated::BASE`),不是它自己。
pub fn base(mut self, base: impl Into<Base>) -> Self {
self.base = base.into();
self
}
}
/// 搜索条内的一行内容(bar 与 view 头部共用,保证「展开时头部不跳」——20.6)。
#[allow(clippy::too_many_arguments)]
fn bar_content<'a, M: Clone + 'a>(
ui: &'a Ui,
base: Base,
value: &'a str,
placeholder: &'a str,
trailing: Option<IconKind>,
@@ -102,7 +115,7 @@ fn bar_content<'a, M: Clone + 'a>(
let icon_offset = icon_edge - bar.pad_start;
let ink = if state.is_disabled() {
interaction::disabled_content_over_role(ui, Role::OnSurface, true)
interaction::disabled_content_over_role(ui, base, Role::OnSurface, true)
} else {
ui.color(Role::OnSurface)
};
@@ -190,7 +203,8 @@ pub fn search_bar<'a, M: Clone + 'a>(
let height = ui.metrics().search_bar_height;
let background = if state.is_disabled() {
interaction::disabled_colors(ui, Role::Surface).0
// 禁用:12% 叠在**药丸脚下的底**上(不是药丸自己的 SurfaceContainerHighest)
interaction::disabled_colors(ui, bar.base).0
} else {
// 规格卡里搜索药丸与 filled 字段是**同一个底**(`SurfaceContainerHighest`):
// M229 r1 用的是 `High`,比 filled 字段低一档,两块并排时能看出色差(复审 F2 裁决抬档)。
@@ -212,6 +226,7 @@ pub fn search_bar<'a, M: Clone + 'a>(
container(bar_content(
ui,
bar.base,
bar.value,
bar.placeholder,
bar.trailing,
@@ -269,7 +284,8 @@ pub fn search_view<'a, M: Clone + 'a>(
) -> Element<'a, M> {
// 展开面板**不跟着药丸抬档**:它是浮层(菜单语言,`SurfaceContainerHigh`),
// 规格卡只画了收起的药丸。两块不同档是层级差,不是漏改(复审 F2 只裁药丸)。
let panel = ui.color(Role::SurfaceContainerHigh);
let panel_role = Role::SurfaceContainerHigh;
let panel = ui.color(panel_role);
let header_height = ui.metrics().search_bar_height;
let body_style = ui.style(TypeRole::BodyMedium);
@@ -327,6 +343,7 @@ pub fn search_view<'a, M: Clone + 'a>(
let panel_content = column![
container(bar_content(
ui,
Base::of(panel_role),
view.value,
view.placeholder,
Some(IconKind::Close),
+21 -28
View File
@@ -40,13 +40,12 @@ use iced::advanced::widget::{tree, Tree};
use iced::advanced::{mouse, Clipboard, Shell, Widget};
use iced::{Background, Border, Color, Element, Event, Length, Rectangle, Size};
use unlucky_design::color::Role;
use crate::theme::Ui;
use super::super::interaction::{self, Interaction, Tracker};
use super::geometry::{SliderGeometry, HANDLE_HEIGHT, HANDLE_WIDTH_ACTIVE, STOP_GAP, STOP_SIZE};
use super::super::interaction::{Base, Interaction, Tracker};
use super::geometry::{SliderGeometry, HANDLE_HEIGHT, STOP_GAP, STOP_SIZE};
mod paint;
mod track;
pub use track::TrackGeometry;
@@ -60,6 +59,8 @@ pub struct Slider<'a, M> {
steps: Option<u32>,
on_change: Option<Box<dyn Fn(f32) -> M + 'a>>,
forced: Option<Interaction>,
/// 脚下那一层的底(M234,禁用态合成用)。
base: Base,
}
impl<'a, M> Slider<'a, M> {
@@ -71,6 +72,7 @@ impl<'a, M> Slider<'a, M> {
steps: None,
on_change: None,
forced: None,
base: Base::CANVAS,
}
}
@@ -93,6 +95,14 @@ impl<'a, M> Slider<'a, M> {
self
}
/// **告诉滑块它踩在什么底上**(M234):禁用态的 12% 未激活轨与 38% 内容叠在这个底上。
///
/// 放进卡片/面板里必须给(卡片 = `surfaces::elevated::BASE`)。
pub fn base(mut self, base: impl Into<Base>) -> Self {
self.base = base.into();
self
}
/// 当前比例(量化后)。
fn value(&self) -> f32 {
quantize(self.fraction, self.steps)
@@ -240,30 +250,13 @@ where
.forced
.unwrap_or_else(|| tree.state.downcast_ref::<State>().tracker.state());
let disabled = state.is_disabled();
let (active_color, inactive_color, handle_color, handle_width) = if disabled {
(
interaction::disabled_content_over_role(ui, Role::OnSurface, false),
interaction::disabled_colors(ui, Role::Surface).0,
interaction::disabled_content_over_role(ui, Role::OnSurface, false),
geometry.handle,
)
} else {
// 状态层只叠在 handle 上(D16):α 全部来自 interaction(hover 8 / focus 10 / pressed 10)
let handle_color =
interaction::overlay_color(ui, state, Role::OnSurface, Role::Primary)
.unwrap_or_else(|| ui.color(Role::Primary));
let handle_width = match state {
Interaction::Focused | Interaction::Pressed => HANDLE_WIDTH_ACTIVE,
_ => geometry.handle,
};
(
ui.color(Role::Primary),
ui.color(Role::SecondaryContainer),
handle_color,
handle_width,
)
};
let colors = paint::colors(ui, self.base, state, geometry.handle);
let (active_color, inactive_color, handle_color, handle_width) = (
colors.active,
colors.inactive,
colors.handle,
colors.handle_width,
);
let centre_y = bounds.y + bounds.height / 2.0;
let track_x = bounds.x;
@@ -0,0 +1,55 @@
//! 滑块的颜色求值(M234 从 `slider.rs` 的 `draw` 里搬出来,顺手收一个 [`Base`])。
//!
//! 搬出来的两个理由:① `slider.rs` 已顶到 399 行,加 `.base(..)` 之前必须腾地方;
//! ② 禁用态那三笔颜色原来在 `draw` 里写死 `Role::Surface` 当合成底(M234 的病灶),
//! 搬进纯函数后可以被单测直接钉住「叠在哪个底上」。
use iced::Color;
use unlucky_design::color::Role;
use crate::theme::Ui;
use super::super::super::interaction::{self, Base, Interaction};
use super::super::geometry::HANDLE_WIDTH_ACTIVE;
/// 滑块一笔要的三个颜色 + handle 宽。
#[derive(Debug, Clone, Copy, PartialEq)]
pub(super) struct Colors {
/// 激活段(左侧已走部分)。
pub(super) active: Color,
/// 未激活段。
pub(super) inactive: Color,
/// handle(4×44 竖条)。
pub(super) handle: Color,
/// handle 宽(focus / pressed 收窄)。
pub(super) handle_width: f32,
}
/// 五态 → 三笔颜色(`HANDLE_WIDTH_ACTIVE` 只在 focus/pressed 用)。
///
/// 禁用(11.6):激活段与 handle `OnSurface`@38%、未激活轨 @12%,三者都叠在 `base` 上
/// ——`base` 是滑块**脚下那一层**(卡片 = `surfaces::elevated::BASE`),不是恒定的画布色。
pub(super) fn colors(ui: &Ui, base: Base, state: Interaction, handle: f32) -> Colors {
if state.is_disabled() {
return Colors {
active: interaction::disabled_content_over_role(ui, base, Role::OnSurface, false),
inactive: interaction::disabled_colors(ui, base).0,
handle: interaction::disabled_content_over_role(ui, base, Role::OnSurface, false),
handle_width: handle,
};
}
// 状态层只叠在 handle 上(D16):α 全部来自 interaction(hover 8 / focus 10 / pressed 10)
let handle_color = interaction::overlay_color(ui, state, Role::OnSurface, Role::Primary)
.unwrap_or_else(|| ui.color(Role::Primary));
let handle_width = match state {
Interaction::Focused | Interaction::Pressed => HANDLE_WIDTH_ACTIVE,
_ => handle,
};
Colors {
active: ui.color(Role::Primary),
inactive: ui.color(Role::SecondaryContainer),
handle: handle_color,
handle_width,
}
}
@@ -0,0 +1,83 @@
//! 禁用态的**底**:控件脚下那一层容器实际画出来的底色(M234)。
//!
//! ## 为什么不是一个 `Role::Surface`
//!
//! 禁用态的容器(按钮 10% / 面状控件 12%)与内容(38%)都要**预先合成成不透明色**
//! (D27:GPU/CPU 两档逐像素一致,所以不许把 α 交给渲染器混合)。合成必须知道「叠在什么上」,
//! 而这个「什么」是**宿主容器**的属性,不是控件的属性:
//!
//! ```text
//! 画布(Surface) ← 页面底色
//! └ 卡片(SurfaceContainer) ← `surfaces::elevated::card`
//! └ 面板(SurfaceContainerLow)← 取景板 / Card 层
//! └ 禁用按钮(OnSurface@10% 叠在面板底上)
//! ```
//!
//! M229 之前 `disabled.rs` 的入口签名是 `(ui, surface: Role)`,而**所有调用点都传死
//! `Role::Surface`** —— 于是卡片里的禁用按钮把 10% 叠在画布色上,出来是一块与卡片底
//! 不协调的灰(M223 记的「CR 1.05、形丢失」)。参数在、语义也在,只是没人给真值。
//!
//! ## 纪律:底由**画那个底的容器**给
//!
//! 每个自己画底的容器**导出 `BASE` 常量**,画的时候用同一个 `Role`、给子控件时给这个常量
//! (见 [`super::super::surfaces::elevated::BASE`]、`inputs::menu::PANEL_BASE`)。两边同源,
//! 改色不会漂一半。
//!
//! iced 没有「环境上下文」(样式闭包只拿得到 `&Theme`,拿不到父容器的样式),所以这条信息
//! **只能显式往下传**:容器 → 控件(`.base(..)`)→ 禁用色求值。控件自己不猜。
//!
//! ## 抓得住 / 抓不住
//!
//! - 抓得住:合成用了哪个 `Role`(`interaction::disabled::tests` 的逐值断言、gallery 的像素 probe);
//! - 抓不住:① 容器画的是**合成色**(半透明面板 α、状态层叠过的底)——`Base` 只装一个 `Role`;
//! ② 调用点给了**错的** `Role`(机制不校验语义,判据只能落在「给的底对不对」的像素上);
//! ③ 忘了调 `.base(..)` 时回落到 [`Base::CANVAS`],编译期不会报错(文档 + 像素 probe 兜)。
use unlucky_design::color::Role;
/// 控件脚下那一层的底(容器自己画的角色,**不是**「反正是 surface」)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Base(Role);
impl Base {
/// 画布底(`Surface`):控件**直接**摆在页面/画布上时用它。
///
/// 这也是各控件 `.base(..)` 的默认值——默认值只对「没有容器」这一种情形成立,
/// 放进卡片/面板/浮层里**必须**换成那个容器的 `BASE`。
pub const CANVAS: Base = Base(Role::Surface);
/// 从容器**画自己底时用的那个角色**构造(容器应导出一个 `BASE` 常量走这条路)。
pub const fn of(role: Role) -> Self {
Self(role)
}
/// 该底对应的角色(合成时取色用)。
pub const fn role(self) -> Role {
self.0
}
}
/// 容器也可以直接把**自己的角色**传进来(`disabled_*` 的入口收 `impl Into<Base>`):
/// 这样历史调用点不必全改签名,但语义只有一条——「你脚下那一层是谁」。
impl From<Role> for Base {
fn from(role: Role) -> Self {
Self(role)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 画布底就是画布角色:`Base` 不许偷偷改语义。
#[test]
fn canvas_is_surface_and_of_keeps_the_role() {
assert_eq!(Base::CANVAS.role(), Role::Surface);
assert_eq!(
Base::of(Role::SurfaceContainer).role(),
Role::SurfaceContainer
);
// 容器底与画布底**不是同一个值**:这一条红了说明 `Base` 把角色丢了
assert_ne!(Base::of(Role::SurfaceContainer), Base::CANVAS);
}
}
@@ -34,46 +34,77 @@ use unlucky_design::color::{Role, Scheme};
use crate::theme::{to_iced, Ui};
use super::base::Base;
// 四个入口都收 `impl Into<Base>`:容器导出的 `BASE` 常量(推荐)与裸 `Role`(历史调用点,
// 例如 `pages/settings/java/tests/marker.rs`)都能进来。**没有隐式默认值**——不给就是编译错误,
// 这一条正是 M229 缺的:参数在、语义在,只是调用点全传了 `Role::Surface`。
/// 禁用态内容色(**按角色、按有没有容器**求值)——A1 口径的入口(**面状控件族**的底)。
///
/// - `base`:控件**脚下那一层的底**([`Base`])。容器给的 `BASE` 常量,不是猜;
/// - `role`:由变体决定(Filled/Outlined/Text/Elevated = `OnSurfaceVariant`;
/// FilledTonal 与三式 IconButton = `OnSurface`,见 `Variant::disabled_content`);
/// - `on_container = true`:叠在**面状控件族的 12% 容器**([`DISABLED_SURFACE_ALPHA`])上
/// (Filled TextField / 有容器控件在 12% 底上求内容时);
/// `false`:直接叠在 **surface** 上(Text / Outlined / standard IconButton / 描边类控件的内容)。
/// (Filled TextField / 有容器控件在 12% 底上求内容时),容器本身叠在 `base` 上;
/// `false`:直接叠在 `base` 上(Text / Outlined / standard IconButton / 描边类控件的内容)。
///
/// **按钮家族不要用这个函数**(按钮容器是 10%):走 [`disabled_button_content`]。
///
/// 之前这里写死 `OnSurfaceVariant` 且只有一个「底」,导致 FilledTonal 与 IconButton 两族偏浅
/// 15/255(视觉 r3 用像素量的)。
pub fn disabled_content_over_role(ui: &Ui, role: Role, on_container: bool) -> Color {
pub fn disabled_content_over_role(
ui: &Ui,
base: impl Into<Base>,
role: Role,
on_container: bool,
) -> Color {
// **取 `Ui` 里已注入的方案**,不重算 baseline:M229 之后产品方案是品牌色板
// (Ink / Silver),照 baseline 重算会画出旧的天蓝中性色(复审 F1)。
disabled_content_with_alpha(ui.scheme(), role, on_container, DISABLED_SURFACE_ALPHA)
disabled_content_with_alpha(
ui.scheme(),
base.into(),
role,
on_container,
DISABLED_SURFACE_ALPHA,
)
}
/// 禁用态内容色——**按钮家族**(`on_container`:是否叠在本族 10% 容器上)。
///
/// 与 [`disabled_content_over_role`] 的差别只有「底」的 α:按钮 10%、面状控件 12%。
/// 两个入口分开是 M86 的结论:合成一个数会让一族永远差 1/255,肉眼看不见、只有 probe 会红。
pub fn disabled_button_content(ui: &Ui, role: Role, on_container: bool) -> Color {
disabled_content_with_alpha(ui.scheme(), role, on_container, DISABLED_CONTAINER_ALPHA)
pub fn disabled_button_content(
ui: &Ui,
base: impl Into<Base>,
role: Role,
on_container: bool,
) -> Color {
disabled_content_with_alpha(
ui.scheme(),
base.into(),
role,
on_container,
DISABLED_CONTAINER_ALPHA,
)
}
/// 两个族共用的内容色求值(「底」的 α 由调用方给)。
fn disabled_content_with_alpha(
scheme: &Scheme,
base: Base,
role: Role,
on_container: bool,
container_alpha: f32,
) -> Color {
let underneath = scheme.get(base.role());
let base = if on_container {
scheme
.get(Role::OnSurface)
.with_alpha(container_alpha)
.over(scheme.get(Role::Surface))
.over(underneath)
} else {
scheme.get(Role::Surface)
underneath
};
to_iced(
@@ -99,29 +130,32 @@ fn disabled_content_with_alpha(
///
/// 顺序必须「先容器后内容」:Compose 也是这么画的,先叠容器再叠 label,
/// 所以内容叠的角色是容器实色而不是底下的 surface。
pub fn disabled_colors(ui: &Ui, surface: Role) -> (Color, Color) {
disabled_colors_with_alpha(ui, surface, DISABLED_SURFACE_ALPHA)
///
/// ⚠️ **`base` 必须是控件脚下那一层的底**([`Base`]):M229 之前这里的参数名叫 `surface`,
/// 而 8 个调用点全传 `Role::Surface`,于是卡片/面板里的禁用控件把 12% 叠在画布色上(M234)。
pub fn disabled_colors(ui: &Ui, base: impl Into<Base>) -> (Color, Color) {
disabled_colors_with_alpha(ui, base.into(), DISABLED_SURFACE_ALPHA)
}
/// 禁用态的一对颜色 `(容器, 内容)`——**按钮家族**(10% 容器)。
///
/// - 容器 = `OnSurface` @ 10%([`DISABLED_CONTAINER_ALPHA`])叠在 `surface` 上;
/// - 容器 = `OnSurface` @ 10%([`DISABLED_CONTAINER_ALPHA`])叠在 `base` 上;
/// - 内容 = **默认** `OnSurfaceVariant` @ 38%;按变体的取值走 [`disabled_button_content`]。
///
/// 除了按钮家族,**没有别的消费者**:面状控件(chip / slider 轨道 / switch 轨道 / 描边)走
/// [`disabled_colors`] 的 12%。
pub fn disabled_button_colors(ui: &Ui, surface: Role) -> (Color, Color) {
disabled_colors_with_alpha(ui, surface, DISABLED_CONTAINER_ALPHA)
pub fn disabled_button_colors(ui: &Ui, base: impl Into<Base>) -> (Color, Color) {
disabled_colors_with_alpha(ui, base.into(), DISABLED_CONTAINER_ALPHA)
}
/// 两个族共用的求值:容器 = `OnSurface` @ `container_alpha` 叠在 `surface` 上,内容 = 38% 叠在容器上。
fn disabled_colors_with_alpha(ui: &Ui, surface: Role, container_alpha: f32) -> (Color, Color) {
/// 两个族共用的求值:容器 = `OnSurface` @ `container_alpha` 叠在 `base` 上,内容 = 38% 叠在容器上。
fn disabled_colors_with_alpha(ui: &Ui, base: Base, container_alpha: f32) -> (Color, Color) {
let scheme = ui.scheme();
let base = scheme.get(surface);
let underneath = scheme.get(base.role());
let container = scheme
.get(Role::OnSurface)
.with_alpha(container_alpha)
.over(base);
.over(underneath);
// 这里是**默认口径**(OnSurfaceVariant);按变体取值请走 [`disabled_content_over_role`]
// ——FilledTonal 与三式 IconButton 的禁用内容是 `OnSurface`(A1)。
let content = scheme
@@ -71,7 +71,7 @@ fn surface_family_disabled_colors_are_the_12_percent_class() {
.with_alpha(EXPECTED_SURFACE_ALPHA)
.over(base);
let (surface, surface_content) = disabled_colors(&ui, Role::Surface);
let (surface, surface_content) = disabled_colors(&ui, Base::CANVAS);
assert_eq!(
surface,
to_iced(expected_container),
@@ -102,7 +102,7 @@ fn surface_family_disabled_colors_are_the_12_percent_class() {
"{flavor:?}:内容必须叠在容器上"
);
// 跨族对照:面状族的合成色必须与按钮族不同(两族一旦合流,这条红)
let (button, _) = disabled_button_colors(&ui, Role::Surface);
let (button, _) = disabled_button_colors(&ui, Base::CANVAS);
assert_ne!(
surface, button,
"{flavor:?}:面状族 12% 与按钮族 10% 必须落在不同像素值上"
@@ -133,7 +133,7 @@ fn button_family_disabled_colors_are_the_10_percent_class() {
.with_alpha(EXPECTED_BUTTON_ALPHA)
.over(base);
let (button, button_content) = disabled_button_colors(&ui, Role::Surface);
let (button, button_content) = disabled_button_colors(&ui, Base::CANVAS);
assert_eq!(
button,
to_iced(expected_container),
@@ -149,7 +149,7 @@ fn button_family_disabled_colors_are_the_10_percent_class() {
),
);
let (surface, _) = disabled_colors(&ui, Role::Surface);
let (surface, _) = disabled_colors(&ui, Base::CANVAS);
assert_ne!(
button, surface,
"{flavor:?}:10% 与 12% 必须落在不同像素值上(合成一个数就是这次要防的回归)"
@@ -158,18 +158,18 @@ fn button_family_disabled_colors_are_the_10_percent_class() {
// 内容色同理:按钮 38% 叠 10% 底 ≠ 面状 38% 叠 12% 底
assert_ne!(
button_content,
disabled_content_over_role(&ui, Role::OnSurfaceVariant, true),
disabled_content_over_role(&ui, Base::CANVAS, Role::OnSurfaceVariant, true),
"{flavor:?}:两族的「有容器」内容色不能同源"
);
assert_eq!(
disabled_button_content(&ui, Role::OnSurfaceVariant, true),
disabled_button_content(&ui, Base::CANVAS, Role::OnSurfaceVariant, true),
button_content,
"{flavor:?}:按钮内容入口必须与按钮颜色入口同源"
);
// 无容器时两族完全一致(都叠 surface)——差异只在「有容器」那一支
assert_eq!(
disabled_button_content(&ui, Role::OnSurfaceVariant, false),
disabled_content_over_role(&ui, Role::OnSurfaceVariant, false),
disabled_button_content(&ui, Base::CANVAS, Role::OnSurfaceVariant, false),
disabled_content_over_role(&ui, Base::CANVAS, Role::OnSurfaceVariant, false),
"{flavor:?}:没有容器时不该有族差异"
);
}
@@ -192,7 +192,7 @@ fn disabled_colors_follow_the_brand_board_not_the_tone_baseline() {
let baseline = Scheme::baseline(flavor);
// ① 面状控件族的容器(chip 描边 / slider 未激活轨 / switch 轨道)
let (surface, _surface_content) = disabled_colors(&ui, Role::Surface);
let (surface, _surface_content) = disabled_colors(&ui, Base::CANVAS);
let expect_surface = to_iced(
board
.get(Role::OnSurface)
@@ -216,12 +216,12 @@ fn disabled_colors_follow_the_brand_board_not_the_tone_baseline() {
);
// ② 按钮族的容器
let (button, _) = disabled_button_colors(&ui, Role::Surface);
let (button, _) = disabled_button_colors(&ui, Base::CANVAS);
assert_ne!(button, surface, "{flavor:?}:两族 α 仍要可区分");
// ③ 内容色(两个入口)
assert_eq!(
disabled_content_over_role(&ui, Role::OnSurfaceVariant, true),
disabled_content_over_role(&ui, Base::CANVAS, Role::OnSurfaceVariant, true),
to_iced(
board
.get(Role::OnSurfaceVariant)
@@ -236,7 +236,7 @@ fn disabled_colors_follow_the_brand_board_not_the_tone_baseline() {
"{flavor:?}:面状控件族内容色要按色板合成"
);
assert_eq!(
disabled_button_content(&ui, Role::OnSurfaceVariant, true),
disabled_button_content(&ui, Base::CANVAS, Role::OnSurfaceVariant, true),
to_iced(
board
.get(Role::OnSurfaceVariant)
@@ -267,6 +267,110 @@ fn disabled_colors_follow_the_brand_board_not_the_tone_baseline() {
}
}
/// **M234 的主判据**:禁用态的合成必须用**给的底**,不是画布色。
///
/// 卡片(`SurfaceContainer`)与面板(`SurfaceContainerLow`)里各放一个禁用控件时,
/// 10%(按钮族)/ 12%(面状族)那一层要叠在**卡/面板的底**上;叠在 `Surface` 上就是 M223
/// 记的「CR 1.05、形丢失」。
///
/// 抓得住:① 入口把 `base` 丢了(内部又写死 `Role::Surface`);② 入口把两个族的 α 合成一个数;
/// ③ 调用点给了画布底(这条测试只证「给了卡片底 → 算出卡片色」,调用点是否真的给了由
/// `buttons::tests::a_disabled_button_composites_over_the_base_it_is_given` 与 gallery 的像素
/// probe 分别守)。
///
/// 抓不住:① 容器画的是**合成色**(半透明面板 α、叠过状态层的底)——`Base` 只装一个 `Role`;
/// ② 卡/面板组件**画底时**用的角色与它导出的 `BASE` 不一致(`BASE` 的值本身由
/// `surfaces::elevated::tests::the_card_base_constant_is_the_role_the_card_paints` 钉住,
/// 「card() 真的用了它」与「页面里的调用点传了它对不对」由 `gallery::probe` 的 disabledbase
/// 段守);③ GPU(wgpu) 档(本测试是纯值,两档同源同值)。
#[test]
fn disabled_paint_composites_over_the_base_it_is_given() {
use unlucky_design::color::BrandBoard;
use unlucky_design::color::Role;
const BUTTON_ALPHA: f32 = 0.10;
const SURFACE_ALPHA: f32 = 0.12;
const CONTENT_ALPHA: f32 = 0.38;
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let ui = ui(flavor);
let scheme = BrandBoard::for_flavor(flavor).scheme();
let card = Base::of(Role::SurfaceContainer);
let panel = Base::of(Role::SurfaceContainerLow);
// 期望值一律**独立算**(写死 α + 直接取色板角色),不调被测常量。
let over = |base: Role, alpha: f32| {
to_iced(
scheme
.get(Role::OnSurface)
.with_alpha(alpha)
.over(scheme.get(base)),
)
};
// ① 按钮族 10%:卡片底 / 面板底 / 画布底三档互不相等
let (card_button, card_button_ink) = disabled_button_colors(&ui, card);
let (panel_button, _) = disabled_button_colors(&ui, panel);
let (canvas_button, canvas_button_ink) = disabled_button_colors(&ui, Base::CANVAS);
assert_eq!(
card_button,
over(Role::SurfaceContainer, BUTTON_ALPHA),
"{flavor:?}:卡片里的禁用按钮容器 = on-surface@10% 叠**卡片底**"
);
assert_eq!(
panel_button,
over(Role::SurfaceContainerLow, BUTTON_ALPHA),
"{flavor:?}:面板里的禁用按钮容器 = on-surface@10% 叠**面板底**"
);
assert_ne!(card_button, canvas_button, "{flavor:?}:卡片底 ≠ 画布底");
assert_ne!(panel_button, canvas_button, "{flavor:?}:面板底 ≠ 画布底");
assert_ne!(card_button, panel_button, "{flavor:?}:卡片底 ≠ 面板底");
// ② 面状控件族 12%(字段容器 / chip 容器 / switch 轨道)
let (card_surface, _) = disabled_colors(&ui, card);
let (panel_surface, _) = disabled_colors(&ui, panel);
let (canvas_surface, _) = disabled_colors(&ui, Base::CANVAS);
assert_eq!(
card_surface,
over(Role::SurfaceContainer, SURFACE_ALPHA),
"{flavor:?}:卡片里的禁用字段容器 = on-surface@12% 叠**卡片底**"
);
assert_eq!(
panel_surface,
over(Role::SurfaceContainerLow, SURFACE_ALPHA),
"{flavor:?}:面板里的禁用字段容器 = on-surface@12% 叠**面板底**"
);
assert_ne!(card_surface, canvas_surface);
assert_ne!(panel_surface, canvas_surface);
// ③ 内容色也跟着底走(38% 叠在容器上 / 直接叠在底上,两支都要换底)
assert_eq!(
disabled_button_content(&ui, card, Role::OnSurfaceVariant, true),
to_iced(
scheme
.get(Role::OnSurfaceVariant)
.with_alpha(CONTENT_ALPHA)
.over(
scheme
.get(Role::OnSurface)
.with_alpha(BUTTON_ALPHA)
.over(scheme.get(Role::SurfaceContainer))
)
),
"{flavor:?}:有容器的内容色 = 38% 叠(10% 叠卡片底)"
);
assert_ne!(
card_button_ink, canvas_button_ink,
"{flavor:?}:内容叠的容器换了底"
);
assert_ne!(
disabled_content_over_role(&ui, card, Role::OnSurface, false),
disabled_content_over_role(&ui, Base::CANVAS, Role::OnSurface, false),
"{flavor:?}:无容器时 38% 也必须叠在**给的底**上"
);
}
}
/// iced 颜色 → 8bit 通道(像素值,报告里的证据就用它)。
fn channels(color: Color) -> (u8, u8, u8) {
(
@@ -39,6 +39,13 @@
//! 上表右列是**唯一**允许的取值口;左列只是给人看的,代码里一个数字都不许出现
//! (`components::gate` 会扫)。
//!
//! ## 合成的「底」是参数,不是常量(M234)
//!
//! 四个入口都收一个 [`Base`]——控件**脚下那一层的底**,由画那个底的容器导出
//! (`surfaces::elevated::BASE` / `inputs::menu::PANEL_BASE` / 各控件的 `.base(..)`)。
//! M229 之前这个值恒等于 `Role::Surface`,于是卡片/面板里的禁用控件把 10%/12% 叠在画布色上
//! (M223 的「CR 1.05、形丢失」)。**不许**在入口内部再写一个默认值回来。
//!
//! ## 两个消费形态
//!
//! 1. **iced `button` 系**(本 mission 的按钮家族):拿 [`Interaction`] 直接配 [`overlay_color`];
@@ -144,8 +151,10 @@ pub fn overlay_color(ui: &Ui, state: Interaction, content: Role, container: Role
.map(|layer| ui.state_over(layer, content, container))
}
mod base;
mod disabled;
pub use base::Base;
pub use disabled::{
disabled_button_colors, disabled_button_content, disabled_colors, disabled_content_over_role,
};
@@ -34,12 +34,19 @@ use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
use super::super::divider;
use super::super::interaction::Base;
use super::geometry::{NavLayout, NavPaneGeometry};
mod item;
use item::{line, nav_row};
pub use item::{NavGroup, NavItem};
/// 导航容器的底:§21.3 标准(常驻)= `Surface` + L0 —— 所以项的禁用内容叠在画布色上。
///
/// 与 [`navigation`] 的 `ui.layer(MaterialLayer::Canvas)` 同源(`Canvas` 的 surface_role 就是
/// `Surface`),M234 之后「底」由这里给,项自己不猜。
pub const BASE: Base = Base::of(Role::Surface);
/// 导航容器(D33:`layout` 决定 88 还是 240,其余全同)。
#[derive(Debug, Clone)]
pub struct Navigation<'a, M> {
@@ -110,7 +117,7 @@ pub fn navigation<'a, M: Clone + 'a>(ui: &'a Ui, nav: Navigation<'a, M>) -> Elem
);
}
for item in group.items {
body = body.push(nav_row(ui, item, geometry));
body = body.push(nav_row(ui, BASE, item, geometry));
}
}
@@ -14,7 +14,7 @@ use unlucky_design::typography::TypeRole;
use crate::icons;
use crate::theme::Ui;
use super::super::super::interaction::{self, Interaction};
use super::super::super::interaction::{self, Base, Interaction};
use super::super::super::layout;
use super::super::geometry::{NavLayout, NavPaneGeometry};
@@ -109,12 +109,13 @@ pub(super) struct ItemPalette {
pub(super) fn palette(
ui: &Ui,
base: Base,
layout: NavLayout,
state: Interaction,
selected: bool,
) -> ItemPalette {
if state.is_disabled() {
let ink = interaction::disabled_content_over_role(ui, Role::OnSurface, false);
let ink = interaction::disabled_content_over_role(ui, base, Role::OnSurface, false);
return ItemPalette {
pill: None,
icon: ink,
@@ -147,7 +148,7 @@ pub(super) fn palette(
_ => ui.color(Role::OnSurface),
};
ItemPalette {
pill: interaction::overlay_color(ui, state, Role::OnSurface, Role::Surface),
pill: interaction::overlay_color(ui, state, Role::OnSurface, base.role()),
icon: ink,
label: ink,
}
@@ -174,6 +175,7 @@ pub(super) fn line<'a, M: 'a>(
/// 一项(rail:图标在上、标签在下;drawer:图标在左、标签在右)。
pub(super) fn nav_row<'a, M: Clone + 'a>(
ui: &'a Ui,
base: Base,
item: NavItem<'a, M>,
geometry: NavPaneGeometry,
) -> Element<'a, M> {
@@ -183,7 +185,7 @@ pub(super) fn nav_row<'a, M: Clone + 'a>(
None
});
let state = forced.unwrap_or(Interaction::Enabled);
let colors = palette(ui, geometry.layout, state, item.selected);
let colors = palette(ui, base, geometry.layout, state, item.selected);
let radius = iced::border::Radius::new(geometry.pill_height / 2.0);
let icon = layout::fixed(
@@ -26,7 +26,7 @@ fn ui() -> Ui {
#[test]
fn drawer_selected_uses_secondary_container() {
let ui = ui();
let palette = palette(&ui, NavLayout::Drawer, Interaction::Enabled, true);
let palette = palette(&ui, BASE, NavLayout::Drawer, Interaction::Enabled, true);
assert_eq!(palette.pill, Some(ui.color(Role::SecondaryContainer)));
assert_eq!(palette.icon, ui.color(Role::OnSecondaryContainer));
assert_eq!(palette.label, ui.color(Role::OnSecondaryContainer));
@@ -36,8 +36,8 @@ fn drawer_selected_uses_secondary_container() {
#[test]
fn rail_selected_label_is_secondary() {
let ui = ui();
let rail = palette(&ui, NavLayout::Rail, Interaction::Enabled, true);
let drawer = palette(&ui, NavLayout::Drawer, Interaction::Enabled, true);
let rail = palette(&ui, BASE, NavLayout::Rail, Interaction::Enabled, true);
let drawer = palette(&ui, BASE, NavLayout::Drawer, Interaction::Enabled, true);
assert_eq!(rail.label, ui.color(Role::Secondary));
assert_ne!(rail.label, drawer.label, "两条线的选中标签角色不同");
// 但图标与药丸相同(同一套语言)
@@ -49,7 +49,7 @@ fn rail_selected_label_is_secondary() {
#[test]
fn inactive_lifts_to_on_surface() {
let ui = ui();
let resting = palette(&ui, NavLayout::Drawer, Interaction::Enabled, false);
let resting = palette(&ui, BASE, NavLayout::Drawer, Interaction::Enabled, false);
assert_eq!(resting.label, ui.color(Role::OnSurfaceVariant));
assert_eq!(resting.pill, None);
for state in [
@@ -57,7 +57,7 @@ fn inactive_lifts_to_on_surface() {
Interaction::Focused,
Interaction::Pressed,
] {
let active = palette(&ui, NavLayout::Drawer, state, false);
let active = palette(&ui, BASE, NavLayout::Drawer, state, false);
assert_eq!(active.label, ui.color(Role::OnSurface), "{state:?}");
assert_eq!(active.icon, ui.color(Role::OnSurface), "{state:?}");
assert!(active.pill.is_some(), "{state:?} 必须叠状态层");
@@ -68,10 +68,10 @@ fn inactive_lifts_to_on_surface() {
#[test]
fn disabled_draws_no_pill() {
let ui = ui();
let palette = palette(&ui, NavLayout::Drawer, Interaction::Disabled, true);
let palette = palette(&ui, BASE, NavLayout::Drawer, Interaction::Disabled, true);
assert_eq!(
palette.icon,
interaction::disabled_content_over_role(&ui, Role::OnSurface, false)
interaction::disabled_content_over_role(&ui, BASE, Role::OnSurface, false)
);
assert_eq!(palette.label, palette.icon);
assert_eq!(palette.pill, None);
@@ -27,8 +27,9 @@
//! ListItemDisabledTrailingIconColor = OnSurface (+ 0.38f)
//! ```
//!
//! 所以这里必须走 `interaction::disabled_content_over_role(ui, OnSurface, false)`;
//! 用默认的 `disabled_colors()`(写死 `OnSurfaceVariant`)会偏浅一档。
//! 所以这里必须走 `interaction::disabled_content_over_role(ui, base, OnSurface, false)`
//! (`base` = 行脚下的底,默认 [`Base::CANVAS`]);用默认的 `disabled_colors()`
//! (写死 `OnSurfaceVariant`)会偏浅一档。
//!
//! ## 选中态:本项目口径(**偏离登记 D-M79-1**)
//!
@@ -49,7 +50,7 @@ use unlucky_design::shape::ShapeScale;
use crate::icons;
use crate::theme::Ui;
use super::super::interaction::{self, Interaction};
use super::super::interaction::{self, Base, Interaction};
use super::super::layout;
use super::geometry::{
typography_headline, typography_overline, typography_supporting, ListItemGeometry,
@@ -66,6 +67,8 @@ pub struct ListItem<'a, M> {
on_press: Option<M>,
selected: bool,
state: Option<Interaction>,
/// 脚下那一层的底(M234,禁用态合成用)。
base: Base,
}
impl<'a, M> ListItem<'a, M> {
@@ -80,6 +83,7 @@ impl<'a, M> ListItem<'a, M> {
on_press: Some(on_press),
selected: false,
state: None,
base: Base::CANVAS,
}
}
@@ -119,6 +123,12 @@ impl<'a, M> ListItem<'a, M> {
self
}
/// **告诉列表项它踩在什么底上**(M234):禁用态的 38% 内容叠在这个底上。
pub fn base(mut self, base: impl Into<Base>) -> Self {
self.base = base.into();
self
}
/// 转禁用:**只清消息**,槽位与选中态一律保留。
pub fn disabled(mut self) -> Self {
self.on_press = None;
@@ -141,10 +151,10 @@ struct ItemPalette {
trailing: Color,
}
fn palette(ui: &Ui, state: Interaction, selected: bool) -> ItemPalette {
fn palette(ui: &Ui, base: Base, state: Interaction, selected: bool) -> ItemPalette {
if state.is_disabled() {
// A1:ListItemDisabled*Color = OnSurface × 0.38(无容器时叠在 surface 上)
let ink = interaction::disabled_content_over_role(ui, Role::OnSurface, false);
let ink = interaction::disabled_content_over_role(ui, base, Role::OnSurface, false);
return ItemPalette {
fill: None,
headline: ink,
@@ -173,8 +183,9 @@ fn palette(ui: &Ui, state: Interaction, selected: bool) -> ItemPalette {
}
ItemPalette {
// 状态层叠在**行自己的底**(Surface)上,得到不透明结果(D27)
fill: interaction::overlay_color(ui, state, Role::OnSurface, Role::Surface),
// 状态层叠在**行脚下的底**(`base`)上,得到不透明结果(D27)。
// M234 复审 P2-3:原来写死 `Role::Surface`,卡片里的列表项抬起时会叠在画布色上。
fill: interaction::overlay_color(ui, state, Role::OnSurface, base.role()),
headline: ui.color(Role::OnSurface),
supporting: ui.color(Role::OnSurfaceVariant),
leading: ui.color(Role::OnSurfaceVariant),
@@ -213,7 +224,12 @@ pub fn list_item<'a, M: Clone + 'a>(ui: &'a Ui, item: ListItem<'a, M>) -> Elemen
} else {
None
});
let colors = palette(ui, forced.unwrap_or(Interaction::Enabled), item.selected);
let colors = palette(
ui,
item.base,
forced.unwrap_or(Interaction::Enabled),
item.selected,
);
let mut body = iced::widget::Column::new().spacing(0);
if let Some(overline) = item.overline {
@@ -268,9 +284,10 @@ pub fn list_item<'a, M: Clone + 'a>(ui: &'a Ui, item: ListItem<'a, M>) -> Elemen
.align_y(Alignment::Center);
let selected = item.selected;
let base = item.base;
let style = move |_theme: &iced::Theme, status: button::Status| {
let state = forced.unwrap_or_else(|| Interaction::from_status(status));
let colors = palette(ui, state, selected);
let colors = palette(ui, base, state, selected);
button::Style {
background: colors.fill.map(Background::Color),
text_color: colors.headline,
@@ -56,14 +56,15 @@ fn disabled_only_clears_the_message() {
#[test]
fn disabled_content_uses_on_surface() {
let ui = ui();
let palette = palette(&ui, Interaction::Disabled, false);
let expected = interaction::disabled_content_over_role(&ui, Role::OnSurface, false);
let palette = palette(&ui, Base::CANVAS, Interaction::Disabled, false);
let expected =
interaction::disabled_content_over_role(&ui, Base::CANVAS, Role::OnSurface, false);
assert_eq!(palette.headline, expected);
assert_eq!(palette.leading, expected);
assert_eq!(palette.trailing, expected);
assert_ne!(
palette.headline,
interaction::disabled_content_over_role(&ui, Role::OnSurfaceVariant, false),
interaction::disabled_content_over_role(&ui, Base::CANVAS, Role::OnSurfaceVariant, false),
"走默认的 OnSurfaceVariant 就会偏浅一档(这条钉住 A1 的角色)"
);
assert!(palette.fill.is_none(), "禁用不叠状态层容器");
@@ -73,7 +74,7 @@ fn disabled_content_uses_on_surface() {
#[test]
fn selected_row_is_secondary_container() {
let ui = ui();
let palette = palette(&ui, Interaction::Enabled, true);
let palette = palette(&ui, Base::CANVAS, Interaction::Enabled, true);
assert_eq!(palette.fill, Some(ui.color(Role::SecondaryContainer)));
assert_eq!(palette.headline, ui.color(Role::OnSecondaryContainer));
assert_eq!(palette.trailing, ui.color(Role::Primary), "4.8");
@@ -83,13 +84,13 @@ fn selected_row_is_secondary_container() {
#[test]
fn interaction_states_keep_the_content_colors() {
let ui = ui();
let resting = palette(&ui, Interaction::Enabled, false);
let resting = palette(&ui, Base::CANVAS, Interaction::Enabled, false);
for state in [
Interaction::Hovered,
Interaction::Focused,
Interaction::Pressed,
] {
let active = palette(&ui, state, false);
let active = palette(&ui, Base::CANVAS, state, false);
assert_eq!(active.headline, resting.headline, "{state:?} 不许改文本色");
assert_eq!(active.leading, resting.leading, "{state:?} 不许改图标色");
assert!(active.fill.is_some(), "{state:?} 必须叠状态层");
@@ -127,11 +127,13 @@ fn list_item_rows_are_44_60_76() {
let darkest = shot.darkest(&disabled).expect("禁用格里应有墨迹");
let right = rgb(interaction::disabled_content_over_role(
&ui,
interaction::Base::CANVAS,
Role::OnSurface,
false,
));
let wrong = rgb(interaction::disabled_content_over_role(
&ui,
interaction::Base::CANVAS,
Role::OnSurfaceVariant,
false,
));
@@ -50,7 +50,7 @@ use unlucky_design::typography::TypeRole;
use crate::icons;
use crate::theme::Ui;
use super::super::interaction::{self, Interaction};
use super::super::interaction::{self, Base, Interaction};
use super::super::layout;
use super::geometry::{TabsGeometry, TABS_ICON_HEIGHT};
@@ -133,6 +133,8 @@ impl<'a, M> Tab<'a, M> {
pub struct Tabs<'a, M> {
variant: TabsVariant,
tabs: Vec<Tab<'a, M>>,
/// 脚下那一层的底(M234,禁用态合成用)。
base: Base,
}
impl<'a, M> Tabs<'a, M> {
@@ -141,6 +143,7 @@ impl<'a, M> Tabs<'a, M> {
Self {
variant: TabsVariant::Primary,
tabs: Vec::new(),
base: Base::CANVAS,
}
}
@@ -149,6 +152,7 @@ impl<'a, M> Tabs<'a, M> {
Self {
variant: TabsVariant::Secondary,
tabs: Vec::new(),
base: Base::CANVAS,
}
}
@@ -157,14 +161,20 @@ impl<'a, M> Tabs<'a, M> {
self.tabs.push(tab);
self
}
/// **告诉 tab 条它踩在什么底上**(M234):禁用态的 38% 标签/指示条叠在这个底上。
pub fn base(mut self, base: impl Into<Base>) -> Self {
self.base = base.into();
self
}
}
/// 一个 tab 的内容色(A1 的 `Active*` / `Inactive*` / 各态表,见模块文档)。
fn content_color(ui: &Ui, primary: bool, state: Interaction, selected: bool) -> Color {
fn content_color(ui: &Ui, base: Base, primary: bool, state: Interaction, selected: bool) -> Color {
if state.is_disabled() {
// A1 的 Tabs 没有 Disabled* token(实读:Primary/Secondary 两套都只有 Active/Inactive × 四态),
// 按 ListItem 的 38% 口径处理 —— 登记 `[估]`。
return interaction::disabled_content_over_role(ui, Role::OnSurface, false);
return interaction::disabled_content_over_role(ui, base, Role::OnSurface, false);
}
if selected {
// 6.5:Primary 选中 `Primary`;Secondary 选中 `OnSurface`
@@ -182,13 +192,14 @@ fn content_color(ui: &Ui, primary: bool, state: Interaction, selected: bool) ->
}
/// Primary 指示条的颜色(只有选中项有;禁用项降档,否则「点不动却亮着」)。
fn bar_color(ui: &Ui, state: Interaction, selected: bool) -> Option<Color> {
fn bar_color(ui: &Ui, base: Base, state: Interaction, selected: bool) -> Option<Color> {
if !selected {
return None;
}
if state.is_disabled() {
return Some(interaction::disabled_content_over_role(
ui,
base,
Role::OnSurface,
false,
));
@@ -207,6 +218,7 @@ fn secondary_line_color(ui: &Ui) -> Color {
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();
let base = tabs.base;
// 6.1:带图标时容器高 **64**(`IconAndLabelTextContainerHeight`),纯文字 48
let height = if tabs.tabs.iter().any(|tab| tab.icon.is_some()) {
TABS_ICON_HEIGHT
@@ -218,7 +230,7 @@ pub fn tabs<'a, M: Clone + 'a>(ui: &'a Ui, tabs: Tabs<'a, M>) -> Element<'a, M>
.height(Length::Fixed(height))
.align_y(Alignment::Center);
for tab in tabs.tabs {
row = row.push(tab_cell(ui, tab, geometry, primary, height));
row = row.push(tab_cell(ui, base, tab, geometry, primary, height));
}
let body: Element<'a, M> = if primary {
@@ -254,8 +266,10 @@ pub fn tabs<'a, M: Clone + 'a>(ui: &'a Ui, tabs: Tabs<'a, M>) -> Element<'a, M>
}
/// 一个 tab 单元(内容 + 底部条)。
#[allow(clippy::too_many_arguments)]
fn tab_cell<'a, M: Clone + 'a>(
ui: &'a Ui,
base: Base,
tab: Tab<'a, M>,
geometry: TabsGeometry,
primary: bool,
@@ -268,7 +282,7 @@ fn tab_cell<'a, M: Clone + 'a>(
});
let state = forced.unwrap_or(Interaction::Enabled);
let selected = tab.selected;
let color = content_color(ui, primary, state, selected);
let color = content_color(ui, base, primary, state, selected);
let style = ui.style(TypeRole::TitleSmall);
let label = text(tab.label)
@@ -291,7 +305,7 @@ fn tab_cell<'a, M: Clone + 'a>(
let bar_height = geometry.bar(primary);
// 底层:内容自己决定宽度(Shrink),底部留出条的高度 —— 未选中的 Primary tab 也占同样的高
let base = container(content)
let cell = container(content)
.height(Length::Fixed(height))
// Q21:要给居中就两轴都显式给(`Container` 默认 Left/Top)
.align_x(Alignment::Center)
@@ -305,12 +319,12 @@ fn tab_cell<'a, M: Clone + 'a>(
// Primary:逐 tab 画条(要跟随该 tab 的宽度);Secondary:整行一条,在 `tabs()` 里画
let bar = if primary {
bar_color(ui, state, selected)
bar_color(ui, base, state, selected)
} else {
None
};
let radius = geometry.indicator_radius();
let mut stack = Stack::new().push(base);
let mut stack = Stack::new().push(cell);
if let Some(color) = bar {
// 上层:Fill —— `Stack` 先按底层定尺寸,所以这里的 Fill = 这个 tab 的宽(不是整行)
stack = stack.push(
@@ -333,7 +347,7 @@ fn tab_cell<'a, M: Clone + 'a>(
);
}
let fill = interaction::overlay_color(ui, state, Role::OnSurface, Role::Surface);
let fill = interaction::overlay_color(ui, state, Role::OnSurface, base.role());
let button = button(stack)
.height(Length::Fixed(height))
.padding(0)
@@ -7,7 +7,7 @@ use unlucky_design::typography::Platform;
use crate::theme::Families;
use super::super::super::interaction;
use super::super::super::interaction::{self, Base};
fn ui() -> Ui {
Ui::with_platform(
@@ -26,16 +26,16 @@ fn ui() -> Ui {
fn active_colors_follow_the_variant() {
let ui = ui();
assert_eq!(
content_color(&ui, true, Interaction::Enabled, true),
content_color(&ui, Base::CANVAS, true, Interaction::Enabled, true),
ui.color(Role::Primary)
);
assert_eq!(
content_color(&ui, false, Interaction::Enabled, true),
content_color(&ui, Base::CANVAS, false, Interaction::Enabled, true),
ui.color(Role::OnSurface),
"6.5:Secondary 的选中项是 OnSurface"
);
assert_ne!(
content_color(&ui, false, Interaction::Enabled, true),
content_color(&ui, Base::CANVAS, false, Interaction::Enabled, true),
ui.color(Role::Primary),
"把 Secondary 的选中色写成 Primary 是最常见的串线"
);
@@ -47,7 +47,7 @@ fn inactive_states_lift_to_on_surface() {
let ui = ui();
for primary in [true, false] {
assert_eq!(
content_color(&ui, primary, Interaction::Enabled, false),
content_color(&ui, Base::CANVAS, primary, Interaction::Enabled, false),
ui.color(Role::OnSurfaceVariant)
);
for state in [
@@ -56,7 +56,7 @@ fn inactive_states_lift_to_on_surface() {
Interaction::Pressed,
] {
assert_eq!(
content_color(&ui, primary, state, false),
content_color(&ui, Base::CANVAS, primary, state, false),
ui.color(Role::OnSurface),
"{state:?} 必须提到 OnSurface"
);
@@ -68,15 +68,19 @@ fn inactive_states_lift_to_on_surface() {
#[test]
fn primary_bar_only_on_the_selected_tab() {
let ui = ui();
assert_eq!(bar_color(&ui, Interaction::Enabled, false), None);
assert_eq!(
bar_color(&ui, Interaction::Enabled, true),
bar_color(&ui, Base::CANVAS, Interaction::Enabled, false),
None
);
assert_eq!(
bar_color(&ui, Base::CANVAS, Interaction::Enabled, true),
Some(ui.color(Role::Primary))
);
assert_eq!(
bar_color(&ui, Interaction::Disabled, true),
bar_color(&ui, Base::CANVAS, Interaction::Disabled, true),
Some(interaction::disabled_content_over_role(
&ui,
Base::CANVAS,
Role::OnSurface,
false
)),
@@ -22,7 +22,7 @@ use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
use super::super::interaction::Interaction;
use super::super::interaction::{Base, Interaction};
use super::geometry::ControlGeometry;
use super::paint::{self, Frame};
use super::shell::{self, StateSource};
@@ -58,6 +58,8 @@ pub struct Checkbox<'a, M> {
on_toggle: Option<M>,
error: bool,
state: Option<Interaction>,
/// 脚下那一层的底(M234,禁用态合成用)。
base: Base,
}
impl<'a, M> Checkbox<'a, M> {
@@ -82,6 +84,7 @@ impl<'a, M> Checkbox<'a, M> {
on_toggle: Some(on_toggle),
error: false,
state: None,
base: Base::CANVAS,
}
}
@@ -93,6 +96,7 @@ impl<'a, M> Checkbox<'a, M> {
on_toggle: None,
error: false,
state: None,
base: Base::CANVAS,
}
}
@@ -107,6 +111,12 @@ impl<'a, M> Checkbox<'a, M> {
self.state = Some(state);
self
}
/// **告诉复选框它踩在什么底上**(M234):禁用态的 38% 内容/描边叠在这个底上。
pub fn base(mut self, base: impl Into<Base>) -> Self {
self.base = base.into();
self
}
}
/// 画一个复选框(框 + 标签)。
@@ -117,11 +127,12 @@ pub fn checkbox<'a, M: Clone + 'a>(ui: &'a Ui, spec: Checkbox<'a, M>) -> Element
let error = spec.error;
let radius = geometry.radius;
let stroke = geometry.stroke;
let base = spec.base;
let source = StateSource::new(spec.state, spec.on_toggle.is_some());
// 勾的颜色与 hover/pressed 无关(A1:`SelectedHover/Focus/PressedIconColor` 都是 `OnPrimary`),
// 只随「选中 + 禁用」变 —— 所以这里用**静态**态求值(SVG 的颜色在构建期就要定)。
let at_rest = paint::checkbox(ui, filled, error, source.get(), radius, stroke);
let at_rest = paint::checkbox(ui, base, filled, error, source.get(), radius, stroke);
let glyph: Element<'a, M> = if filled {
shell::square(
geometry.side,
@@ -136,7 +147,7 @@ pub fn checkbox<'a, M: Clone + 'a>(ui: &'a Ui, spec: Checkbox<'a, M>) -> Element
.width(Length::Fixed(geometry.side))
.height(Length::Fixed(geometry.side))
.style(source.container_style(move |state| {
paint::checkbox(ui, filled, error, state, radius, stroke)
paint::checkbox(ui, base, filled, error, state, radius, stroke)
.frame
.container_style()
}));
@@ -150,7 +161,12 @@ pub fn checkbox<'a, M: Clone + 'a>(ui: &'a Ui, spec: Checkbox<'a, M>) -> Element
.align_y(Alignment::Center)
.style(source.container_style(move |state| {
Frame {
fill: paint::state_layer(ui, state, paint::checkbox_layer_role(filled, error)),
fill: paint::state_layer(
ui,
base,
state,
paint::checkbox_layer_role(filled, error),
),
outline: None,
stroke: 0.0,
radius: layer_side / 2.0,
@@ -165,7 +181,7 @@ pub fn checkbox<'a, M: Clone + 'a>(ui: &'a Ui, spec: Checkbox<'a, M>) -> Element
text(spec.label)
.size(style.size_px)
.font(ui.font_of(style))
.color(paint::label(ui, source.get())),
.color(paint::label(ui, base, source.get())),
);
}
@@ -42,7 +42,7 @@ use unlucky_design::typography::TypeRole;
use crate::icons;
use crate::theme::Ui;
use super::super::interaction::Interaction;
use super::super::interaction::{Base, Interaction};
use super::geometry::ChipGeometry;
use super::paint::{self, ChipVariant};
@@ -58,6 +58,8 @@ pub struct Chip<'a, M> {
state: Option<Interaction>,
/// 「当前项」标记([`Chip::current`]):选中配色照画,但**不**降成禁用配色、也不吃五态。
marker: bool,
/// 脚下那一层的底(M234,禁用态合成用)。
base: Base,
}
impl<'a, M> Chip<'a, M> {
@@ -94,6 +96,7 @@ impl<'a, M> Chip<'a, M> {
on_press: None,
state: None,
marker: false,
base: Base::CANVAS,
}
}
@@ -116,6 +119,7 @@ impl<'a, M> Chip<'a, M> {
on_press: None,
state: None,
marker: true,
base: Base::CANVAS,
}
}
@@ -140,6 +144,7 @@ impl<'a, M> Chip<'a, M> {
on_press: Some(on_press),
state: None,
marker: false,
base: Base::CANVAS,
}
}
@@ -161,6 +166,12 @@ impl<'a, M> Chip<'a, M> {
self
}
/// **告诉 chip 它踩在什么底上**(M234):禁用态那 12% 容器/描边与 38% 内容叠在这个底上。
pub fn base(mut self, base: impl Into<Base>) -> Self {
self.base = base.into();
self
}
/// 静息时的外观——**唯一**把「这颗 chip 属于哪个档位」翻成颜色的地方。
///
/// `chip()` 用它求文字/图标色,测试也用它量对比度:档位的判据(当前项 / 选中 / 禁用)
@@ -177,7 +188,7 @@ impl<'a, M> Chip<'a, M> {
} else {
self.state.unwrap_or(Interaction::Enabled)
};
paint::chip(ui, self.variant, self.selected, state, radius)
paint::chip(ui, self.base, self.variant, self.selected, state, radius)
}
}
@@ -188,6 +199,7 @@ pub fn chip<'a, M: Clone + 'a>(ui: &'a Ui, spec: Chip<'a, M>) -> Element<'a, M>
let selected = spec.selected;
let marker = spec.marker;
let forced = spec.state;
let base = spec.base;
// 文字/图标色只看「选中 + 禁用」(A1:Filter/Input 的 label 色在 hover/focus/pressed 不变),
// 所以这里静态求值;容器(含状态层)在样式闭包里跟态走。档位的判据在 `Chip::paint` 一处
@@ -241,7 +253,7 @@ pub fn chip<'a, M: Clone + 'a>(ui: &'a Ui, spec: Chip<'a, M>) -> Element<'a, M>
paint::current_item(ui, geometry.radius)
} else {
let state = forced.unwrap_or_else(|| Interaction::from_status(status));
paint::chip(ui, variant, selected, state, geometry.radius)
paint::chip(ui, base, variant, selected, state, geometry.radius)
};
button::Style {
background: paint.frame.fill.map(Background::Color),
@@ -4,7 +4,7 @@ use unlucky_design::color::Role;
use crate::theme::Ui;
use super::super::super::interaction::Interaction;
use super::super::super::interaction::{Base, Interaction};
use super::{disabled_ink, lifted, CheckboxPaint, Frame};
/// Checkbox 的外观。
@@ -13,6 +13,7 @@ use super::{disabled_ink, lifted, CheckboxPaint, Frame};
/// 选中态没有描边(`SelectedOutlineWidth = 0`,走填充分支)。
pub fn checkbox(
ui: &Ui,
base: Base,
selected: bool,
error: bool,
state: Interaction,
@@ -25,8 +26,8 @@ pub fn checkbox(
// A1:`SelectedDisabledContainerColor = OnSurface` +
// `SelectedDisabledContainerOpacity = 0.38` —— **不是** 12%(复审 P2-3)。
// 与未选中禁用的描边是同一个 38% 叠色,两格看起来才成对。
fill: selected.then(|| disabled_ink(ui, Role::OnSurface)),
outline: (!selected).then(|| disabled_ink(ui, Role::OnSurface)),
fill: selected.then(|| disabled_ink(ui, base, Role::OnSurface)),
outline: (!selected).then(|| disabled_ink(ui, base, Role::OnSurface)),
stroke,
radius,
},
@@ -4,7 +4,7 @@ use unlucky_design::color::Role;
use crate::theme::{Ui, STROKE_PX};
use super::super::super::interaction::Interaction;
use super::super::super::interaction::{Base, Interaction};
use super::{disabled_container, disabled_ink, state_layer, ChipPaint, Frame};
/// Chip 的三种(§13.6 / §13.7):Assist / Filter / Input。
@@ -24,6 +24,7 @@ pub enum ChipVariant {
/// 所以未选中 + hover 的 fill 是「状态层叠在 surface 上」的实色。
pub fn chip(
ui: &Ui,
base: Base,
variant: ChipVariant,
selected: bool,
state: Interaction,
@@ -39,11 +40,11 @@ pub fn chip(
};
if state.is_disabled() {
let ink = disabled_ink(ui, Role::OnSurface);
let ink = disabled_ink(ui, base, Role::OnSurface);
return ChipPaint {
frame: Frame {
fill: selected.then(|| disabled_container(ui)),
outline: (!selected).then(|| disabled_container(ui)),
fill: selected.then(|| disabled_container(ui, base)),
outline: (!selected).then(|| disabled_container(ui, base)),
stroke: STROKE_PX,
radius,
},
@@ -56,7 +57,7 @@ pub fn chip(
let fill = if selected {
Some(ui.color(Role::SecondaryContainer))
} else {
state_layer(ui, state, label_role)
state_layer(ui, base, state, label_role)
};
// Assist / 选中态的前置图标是 `Primary`(`AssistChipTokens.IconColor` /
// `InputChipTokens.SelectedLeadingIconColor`);其余跟文字同色。
@@ -102,5 +103,12 @@ pub fn chip(
/// pressed / disabled 五态(见 `Chip::current` 的文档)。对比度下限是 [`crate::components::selection::paint`]
/// 同一条正文口径(WCAG AA 4.5:1,`unlucky_design::palette::wcag::AA_TEXT`)。
pub fn current_item(ui: &Ui, radius: f32) -> ChipPaint {
chip(ui, ChipVariant::Filter, true, Interaction::Enabled, radius)
chip(
ui,
Base::CANVAS,
ChipVariant::Filter,
true,
Interaction::Enabled,
radius,
)
}
@@ -27,7 +27,7 @@ use unlucky_design::color::Role;
use crate::theme::Ui;
use super::super::interaction::{self, Interaction};
use super::super::interaction::{self, Base, Interaction};
mod check;
mod chip;
@@ -109,23 +109,31 @@ pub struct ChipPaint {
pub icon: Color,
}
/// 状态层颜色:`content` 角色 @ 该态的 α 叠在 `surface` 上(不透明;α 的唯一实现点是
/// [`interaction::overlay_color`],本目录零 α 字面量)。
pub fn state_layer(ui: &Ui, state: Interaction, content: Role) -> Option<Color> {
interaction::overlay_color(ui, state, content, Role::Surface)
/// 状态层颜色:`content` 角色 @ 该态的 α 叠在 **`base`(控件脚下的底)** 上
/// (不透明;α 的唯一实现点是 [`interaction::overlay_color`],本目录零 α 字面量)。
///
/// M234 复审 P2-3:禁用支跟了 `base`,hover/focus/pressed 这一支却仍写死 `Role::Surface`
/// —— 卡片里的 checkbox/radio/chip 抬起来时会把状态层叠在画布色上(口径不一致比那 1/255 的
/// 数值更麻烦)。现在两支同源。
pub fn state_layer(ui: &Ui, base: Base, state: Interaction, content: Role) -> Option<Color> {
interaction::overlay_color(ui, state, content, base.role())
}
/// `OnSurface`@12% 叠 surface(**容器**禁用色:Switch 轨道、Chip 未选中描边)。
/// `OnSurface`@12% 叠 `base`(**容器**禁用色:Switch 轨道、Chip 未选中描边)。
///
/// `base` 是控件**脚下那一层的底**(M234):画布 = [`Base::CANVAS`]、卡片 =
/// `surfaces::elevated::BASE`。M229 之前这里写死 `Role::Surface`,卡片里的禁用 chip
/// 于是把 12% 叠在画布色上。
///
/// 注意:Checkbox「选中且禁用」的**填充**不是这个 12%,A1 的
/// `SelectedDisabledContainerOpacity = 0.38` → 见 [`disabled_ink`](复审 P2-3)。
pub(super) fn disabled_container(ui: &Ui) -> Color {
interaction::disabled_colors(ui, Role::Surface).0
pub(super) fn disabled_container(ui: &Ui, base: Base) -> Color {
interaction::disabled_colors(ui, base).0
}
/// 内容禁用色:`role` @38% 叠在 surface 上(`on_container = false`)。
pub(super) fn disabled_ink(ui: &Ui, role: Role) -> Color {
interaction::disabled_content_over_role(ui, role, false)
/// 内容禁用色:`role` @38% 叠在 `base` 上(`on_container = false`)。
pub(super) fn disabled_ink(ui: &Ui, base: Base, role: Role) -> Color {
interaction::disabled_content_over_role(ui, base, role, false)
}
/// hover / focus / pressed(「抬起来」的三态)。
@@ -140,9 +148,9 @@ pub(super) fn lifted(state: Interaction) -> bool {
///
/// A1 里 checkbox/radio 都没有「标签色随 hover 变」的字段(标签不属于控件本体),
/// 所以这里只看禁用与否。
pub fn label(ui: &Ui, state: Interaction) -> Color {
pub fn label(ui: &Ui, base: Base, state: Interaction) -> Color {
if state.is_disabled() {
disabled_ink(ui, Role::OnSurface)
disabled_ink(ui, base, Role::OnSurface)
} else {
ui.color(Role::OnSurface)
}
@@ -4,15 +4,22 @@ use unlucky_design::color::Role;
use crate::theme::Ui;
use super::super::super::interaction::Interaction;
use super::super::super::interaction::{Base, Interaction};
use super::{disabled_ink, lifted, Frame, RadioPaint};
/// Radio 的外观。
///
/// **选中态 hover 不换色**:环与点在四态里都是 `Primary`(只有状态层变)。
pub fn radio(ui: &Ui, selected: bool, state: Interaction, radius: f32, stroke: f32) -> RadioPaint {
pub fn radio(
ui: &Ui,
base: Base,
selected: bool,
state: Interaction,
radius: f32,
stroke: f32,
) -> RadioPaint {
let ink = if state.is_disabled() {
disabled_ink(ui, Role::OnSurface)
disabled_ink(ui, base, Role::OnSurface)
} else if selected {
ui.color(Role::Primary)
} else if lifted(state) {
@@ -7,17 +7,17 @@ use unlucky_design::shape::ShapeScale;
use crate::theme::Ui;
use super::super::super::interaction::{self, Interaction};
use super::super::super::interaction::{self, Base, Interaction};
use super::super::geometry::SwitchGeometry;
use super::{disabled_container, disabled_ink, lifted, Frame, SwitchPaint};
/// Switch 的外观。
pub fn switch(ui: &Ui, selected: bool, state: Interaction) -> SwitchPaint {
pub fn switch(ui: &Ui, base: Base, selected: bool, state: Interaction) -> SwitchPaint {
let geometry = SwitchGeometry::of(ui);
let radius = ShapeScale::Full.radius_px(geometry.track_height);
if state.is_disabled() {
let container = disabled_container(ui);
let container = disabled_container(ui, base);
return if selected {
SwitchPaint {
track: Frame {
@@ -28,7 +28,7 @@ pub fn switch(ui: &Ui, selected: bool, state: Interaction) -> SwitchPaint {
},
// `DisabledSelectedHandleColor = Surface` 且不透明度 1(A1 的 fconst_1)
thumb: ui.color(Role::Surface),
icon: Some(disabled_ink(ui, Role::OnSurface)),
icon: Some(disabled_ink(ui, base, Role::OnSurface)),
}
} else {
SwitchPaint {
@@ -38,7 +38,7 @@ pub fn switch(ui: &Ui, selected: bool, state: Interaction) -> SwitchPaint {
stroke: geometry.track_stroke,
radius,
},
thumb: disabled_ink(ui, Role::OnSurface),
thumb: disabled_ink(ui, base, Role::OnSurface),
icon: None,
}
};
@@ -25,7 +25,7 @@
use unlucky_design::color::{Role, SchemeFlavor};
use unlucky_design::typography::Platform;
use super::super::interaction::{self, Interaction};
use super::super::interaction::{self, Base, Interaction};
use super::pixel::{center, dims, ink_boxes, inner_ink, matches, near, rgb, shot_path, Shot};
use crate::theme::{Families, Ui};
@@ -273,7 +273,7 @@ fn selection_geometry_matches_the_reference() {
// 判据:禁用列里必须**恰好有一块** 12% 填充的 chip(= filter·on)。旧 API 把 selected
// 一起删掉,这一块根本不存在(实测 0 块);能查到它、且宽度 ≥58,说明选中态与图标都还在。
// (未选中的那三个禁用 chip 只有 1px 描边,落在半像素上时覆盖率不足 50%,量不稳,故不在此断言。)
let disabled_container = interaction::disabled_colors(&ui, Role::Surface).0;
let disabled_container = interaction::disabled_colors(&ui, Base::CANVAS).0;
let disabled_chips: Vec<_> = ink_boxes(&shot, disabled_container, surface, 28, 140)
.into_iter()
.filter(|bbox| near(bbox.height(), 32) && bbox.width() >= 58)
@@ -292,7 +292,8 @@ fn selection_geometry_matches_the_reference() {
);
// ---- 复审 P2-3:**选中且禁用**的 Checkbox 容器是 38%,不是 12% -----------------
let disabled_ink = interaction::disabled_content_over_role(&ui, Role::OnSurface, false);
let disabled_ink =
interaction::disabled_content_over_role(&ui, Base::CANVAS, Role::OnSurface, false);
let ink38: Vec<_> = ink_boxes(&shot, disabled_ink, surface, 17, 24)
.into_iter()
.filter(|bbox| near(bbox.width(), 20) && near(bbox.height(), 20))
@@ -22,7 +22,7 @@ use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
use super::super::interaction::Interaction;
use super::super::interaction::{Base, Interaction};
use super::geometry::ControlGeometry;
use super::paint::{self, Frame};
use super::shell::{self, StateSource};
@@ -34,6 +34,8 @@ pub struct Radio<'a, M> {
selected: bool,
on_select: Option<M>,
state: Option<Interaction>,
/// 脚下那一层的底(M234,禁用态合成用)。
base: Base,
}
impl<'a, M> Radio<'a, M> {
@@ -44,6 +46,7 @@ impl<'a, M> Radio<'a, M> {
selected,
on_select: Some(on_select),
state: None,
base: Base::CANVAS,
}
}
@@ -54,6 +57,7 @@ impl<'a, M> Radio<'a, M> {
selected,
on_select: None,
state: None,
base: Base::CANVAS,
}
}
@@ -62,6 +66,12 @@ impl<'a, M> Radio<'a, M> {
self.state = Some(state);
self
}
/// **告诉单选框它踩在什么底上**(M234):禁用态的 38% 环/内点叠在这个底上。
pub fn base(mut self, base: impl Into<Base>) -> Self {
self.base = base.into();
self
}
}
/// 画一个单选框(环 + 内点 + 标签)。
@@ -71,11 +81,12 @@ pub fn radio<'a, M: Clone + 'a>(ui: &'a Ui, spec: Radio<'a, M>) -> Element<'a, M
let radius = geometry.radius;
let stroke = geometry.stroke;
let dot_side = geometry.inner_dot;
let base = spec.base;
let source = StateSource::new(spec.state, spec.on_select.is_some());
// 内点与环的颜色与 hover/pressed 无关(A1:选中态四态都是 `Primary`),
// 只随「选中 + 禁用」变 —— 所以点的颜色在这里静态求值即可。
let at_rest = paint::radio(ui, selected, source.get(), radius, stroke);
let at_rest = paint::radio(ui, base, selected, source.get(), radius, stroke);
// Ø10 内点(半径 = 直径/2)
let dot: Element<'a, M> = match at_rest.dot {
@@ -102,7 +113,7 @@ pub fn radio<'a, M: Clone + 'a>(ui: &'a Ui, spec: Radio<'a, M>) -> Element<'a, M
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.style(source.container_style(move |state| {
paint::radio(ui, selected, state, radius, stroke)
paint::radio(ui, base, selected, state, radius, stroke)
.ring
.container_style()
}));
@@ -116,7 +127,7 @@ pub fn radio<'a, M: Clone + 'a>(ui: &'a Ui, spec: Radio<'a, M>) -> Element<'a, M
.align_y(Alignment::Center)
.style(source.container_style(move |state| {
Frame {
fill: paint::state_layer(ui, state, paint::radio_layer_role(selected)),
fill: paint::state_layer(ui, base, state, paint::radio_layer_role(selected)),
outline: None,
stroke: 0.0,
radius: layer_side / 2.0,
@@ -131,7 +142,7 @@ pub fn radio<'a, M: Clone + 'a>(ui: &'a Ui, spec: Radio<'a, M>) -> Element<'a, M
text(spec.label)
.size(style.size_px)
.font(ui.font_of(style))
.color(paint::label(ui, source.get())),
.color(paint::label(ui, base, source.get())),
);
}
@@ -24,7 +24,7 @@ use unlucky_design::typography::TypeRole;
use crate::icons;
use crate::theme::Ui;
use super::super::interaction::Interaction;
use super::super::interaction::{Base, Interaction};
use super::geometry::SwitchGeometry;
use super::paint::{self, Frame};
use super::shell::{self, StateSource};
@@ -36,6 +36,8 @@ pub struct Switch<'a, M> {
value: bool,
on_toggle: Option<M>,
state: Option<Interaction>,
/// 脚下那一层的底(M234,禁用态合成用)。
base: Base,
}
impl<'a, M> Switch<'a, M> {
@@ -46,6 +48,7 @@ impl<'a, M> Switch<'a, M> {
value,
on_toggle: Some(on_toggle),
state: None,
base: Base::CANVAS,
}
}
@@ -56,6 +59,7 @@ impl<'a, M> Switch<'a, M> {
value,
on_toggle: None,
state: None,
base: Base::CANVAS,
}
}
@@ -64,6 +68,12 @@ impl<'a, M> Switch<'a, M> {
self.state = Some(state);
self
}
/// **告诉开关它踩在什么底上**(M234):禁用态的 12% 轨道 / 38% 滑块叠在这个底上。
pub fn base(mut self, base: impl Into<Base>) -> Self {
self.base = base.into();
self
}
}
/// 画一个开关(轨道 + 滑块 + 标签)。
@@ -71,11 +81,12 @@ pub fn switch<'a, M: Clone + 'a>(ui: &'a Ui, spec: Switch<'a, M>) -> Element<'a,
let geometry = SwitchGeometry::of(ui);
let selected = spec.value;
let thumb_side = geometry.thumb(selected, false);
let base = spec.base;
let source = StateSource::new(spec.state, spec.on_toggle.is_some());
// 滑块内的勾:颜色只随选中/禁用变(A1 的 `Selected*IconColor` 都是 `OnPrimaryContainer`),
// 所以静态求值;未选中态官方图标色 = 轨道色(画了也看不见),因此不画。
let at_rest = paint::switch(ui, selected, source.get());
let at_rest = paint::switch(ui, base, selected, source.get());
let glyph: Element<'a, M> = match at_rest.icon {
Some(ink) => super::super::layout::centered_fixed(
geometry.icon,
@@ -94,7 +105,7 @@ pub fn switch<'a, M: Clone + 'a>(ui: &'a Ui, spec: Switch<'a, M>) -> Element<'a,
.align_y(Alignment::Center)
.style(source.container_style(move |state| {
Frame {
fill: Some(paint::switch(ui, selected, state).thumb),
fill: Some(paint::switch(ui, base, selected, state).thumb),
outline: None,
stroke: 0.0,
radius: thumb_side / 2.0,
@@ -124,7 +135,9 @@ pub fn switch<'a, M: Clone + 'a>(ui: &'a Ui, spec: Switch<'a, M>) -> Element<'a,
.width(Length::Fixed(geometry.track_width))
.height(Length::Fixed(geometry.track_height))
.style(source.container_style(move |state| {
paint::switch(ui, selected, state).track.container_style()
paint::switch(ui, base, selected, state)
.track
.container_style()
}));
// 绘制区 = 轨道 + 状态层外扩;两层叠放(层序:轨道在下,滑块与状态层在上)
@@ -159,7 +172,7 @@ pub fn switch<'a, M: Clone + 'a>(ui: &'a Ui, spec: Switch<'a, M>) -> Element<'a,
text(spec.label)
.size(style.size_px)
.font(ui.font_of(style))
.color(paint::label(ui, source.get())),
.color(paint::label(ui, base, source.get())),
);
}
@@ -6,7 +6,9 @@
use unlucky_design::color::{Role, SchemeFlavor};
use unlucky_design::typography::Platform;
use super::super::interaction::{self, Interaction};
use super::super::interaction::{self, Base, Interaction};
mod chip;
use super::geometry::{ChipGeometry, ControlGeometry, SwitchGeometry};
use super::paint::{self, ChipVariant};
use crate::theme::{Families, Ui};
@@ -156,7 +158,15 @@ fn chip_geometry_matches_the_tokens() {
fn checkbox_unselected_lifts_the_outline_to_on_surface() {
let ui = ui();
let g = ControlGeometry::checkbox(&ui);
let at_rest = paint::checkbox(&ui, false, false, Interaction::Enabled, g.radius, g.stroke);
let at_rest = paint::checkbox(
&ui,
Base::CANVAS,
false,
false,
Interaction::Enabled,
g.radius,
g.stroke,
);
assert_eq!(
at_rest.frame.outline,
Some(ui.color(Role::OnSurfaceVariant))
@@ -166,7 +176,7 @@ fn checkbox_unselected_lifts_the_outline_to_on_surface() {
Interaction::Focused,
Interaction::Pressed,
] {
let lifted = paint::checkbox(&ui, false, false, state, g.radius, g.stroke);
let lifted = paint::checkbox(&ui, Base::CANVAS, false, false, state, g.radius, g.stroke);
assert_eq!(
lifted.frame.outline,
Some(ui.color(Role::OnSurface)),
@@ -175,7 +185,15 @@ fn checkbox_unselected_lifts_the_outline_to_on_surface() {
assert_eq!(lifted.frame.stroke, 2.0, "18.8:描边宽度不随态变");
}
// 错误态优先于 hover
let error = paint::checkbox(&ui, false, true, Interaction::Hovered, g.radius, g.stroke);
let error = paint::checkbox(
&ui,
Base::CANVAS,
false,
true,
Interaction::Hovered,
g.radius,
g.stroke,
);
assert_eq!(error.frame.outline, Some(ui.color(Role::Error)));
}
@@ -184,7 +202,15 @@ fn checkbox_unselected_lifts_the_outline_to_on_surface() {
fn checkbox_disabled_keeps_the_stroke_and_uses_the_a1_roles() {
let ui = ui();
let g = ControlGeometry::checkbox(&ui);
let unchecked = paint::checkbox(&ui, false, false, Interaction::Disabled, g.radius, g.stroke);
let unchecked = paint::checkbox(
&ui,
Base::CANVAS,
false,
false,
Interaction::Disabled,
g.radius,
g.stroke,
);
assert_eq!(unchecked.frame.stroke, 2.0, "18.8:禁用描边仍 2px");
assert_eq!(unchecked.frame.fill, None, "18.8:未选中禁用内部仍透明");
assert!(
@@ -195,7 +221,15 @@ fn checkbox_disabled_keeps_the_stroke_and_uses_the_a1_roles() {
"18.8:禁用描边是 OnSurface@38%,不是满档角色色"
);
let checked = paint::checkbox(&ui, true, false, Interaction::Disabled, g.radius, g.stroke);
let checked = paint::checkbox(
&ui,
Base::CANVAS,
true,
false,
Interaction::Disabled,
g.radius,
g.stroke,
);
assert_eq!(
checked.glyph,
ui.color(Role::Surface),
@@ -203,8 +237,8 @@ fn checkbox_disabled_keeps_the_stroke_and_uses_the_a1_roles() {
);
// 复审 P2-3:**选中且禁用**的填充是 OnSurface@38%(`SelectedDisabledContainerOpacity`),
// 不是普通禁用容器的 12%。两格差一眼就能看出来,所以逐字钉住。
let ink38 = interaction::disabled_content_over_role(&ui, Role::OnSurface, false);
let container12 = interaction::disabled_colors(&ui, Role::Surface).0;
let ink38 = interaction::disabled_content_over_role(&ui, Base::CANVAS, Role::OnSurface, false);
let container12 = interaction::disabled_colors(&ui, Base::CANVAS).0;
assert_eq!(
checked.frame.fill,
Some(ink38),
@@ -232,7 +266,7 @@ fn radio_keeps_primary_on_hover_when_selected() {
if state.is_disabled() {
continue;
}
let paint = paint::radio(&ui, true, state, g.radius, g.stroke);
let paint = paint::radio(&ui, Base::CANVAS, true, state, g.radius, g.stroke);
assert_eq!(
paint.ring.outline, primary,
"19.4:{state:?} 环仍是 Primary"
@@ -240,10 +274,24 @@ fn radio_keeps_primary_on_hover_when_selected() {
assert_eq!(paint.dot, primary, "19.4:{state:?} 点仍是 Primary");
}
// 未选中:静息 OnSurfaceVariant → hover/focus/pressed 提到 OnSurface
let at_rest = paint::radio(&ui, false, Interaction::Enabled, g.radius, g.stroke);
let at_rest = paint::radio(
&ui,
Base::CANVAS,
false,
Interaction::Enabled,
g.radius,
g.stroke,
);
assert_eq!(at_rest.dot, None, "未选中不画内点");
assert_eq!(at_rest.ring.outline, Some(ui.color(Role::OnSurfaceVariant)));
let lifted = paint::radio(&ui, false, Interaction::Hovered, g.radius, g.stroke);
let lifted = paint::radio(
&ui,
Base::CANVAS,
false,
Interaction::Hovered,
g.radius,
g.stroke,
);
assert_eq!(lifted.ring.outline, Some(ui.color(Role::OnSurface)));
}
@@ -251,12 +299,12 @@ fn radio_keeps_primary_on_hover_when_selected() {
#[test]
fn switch_handle_roles_follow_the_tokens() {
let ui = ui();
let at_rest = paint::switch(&ui, true, Interaction::Enabled);
let at_rest = paint::switch(&ui, Base::CANVAS, true, Interaction::Enabled);
assert_eq!(at_rest.track.fill, Some(ui.color(Role::Primary)));
assert_eq!(at_rest.thumb, ui.color(Role::OnPrimary));
assert_eq!(at_rest.icon, Some(ui.color(Role::OnPrimaryContainer)));
let hovered = paint::switch(&ui, true, Interaction::Hovered);
let hovered = paint::switch(&ui, Base::CANVAS, true, Interaction::Hovered);
assert_eq!(
hovered.thumb,
ui.color(Role::PrimaryContainer),
@@ -268,7 +316,7 @@ fn switch_handle_roles_follow_the_tokens() {
"轨道不随 hover 变"
);
let off = paint::switch(&ui, false, Interaction::Enabled);
let off = paint::switch(&ui, Base::CANVAS, false, Interaction::Enabled);
assert_eq!(
off.track.outline,
Some(ui.color(Role::Outline)),
@@ -282,79 +330,10 @@ fn switch_handle_roles_follow_the_tokens() {
);
assert_eq!(off.icon, None, "未选中态图标与轨道同色(不画)");
let disabled = paint::switch(&ui, true, Interaction::Disabled);
let disabled = paint::switch(&ui, Base::CANVAS, true, Interaction::Disabled);
assert_eq!(
disabled.thumb,
ui.color(Role::Surface),
"A1:DisabledSelectedHandleColor=Surface"
);
}
/// §13.5 / §13.6 / §13.7:选中态**无描边**、Assist 不可选中、禁用文字 @38%。
#[test]
fn chip_roles_follow_the_tokens() {
let ui = ui();
let radius = ChipGeometry::of(&ui).radius;
let selected = paint::chip(&ui, ChipVariant::Filter, true, Interaction::Enabled, radius);
assert_eq!(
selected.frame.fill,
Some(ui.color(Role::SecondaryContainer))
);
assert_eq!(selected.frame.outline, None, "13.5:选中描边 0dp");
assert_eq!(selected.label, ui.color(Role::OnSecondaryContainer));
let unselected = paint::chip(
&ui,
ChipVariant::Filter,
false,
Interaction::Enabled,
radius,
);
assert_eq!(
unselected.frame.outline,
Some(ui.color(Role::OutlineVariant))
);
assert_eq!(unselected.label, ui.color(Role::OnSurfaceVariant));
// Assist 没有选中态:即使传 selected = true 也不会变 SecondaryContainer
let assist = paint::chip(&ui, ChipVariant::Assist, true, Interaction::Enabled, radius);
assert_ne!(assist.frame.fill, Some(ui.color(Role::SecondaryContainer)));
assert_eq!(
assist.icon,
ui.color(Role::Primary),
"13.4:Assist 前置图标 Primary"
);
let disabled = paint::chip(
&ui,
ChipVariant::Filter,
false,
Interaction::Disabled,
radius,
);
assert_ne!(disabled.label, unselected.label, "13.7:禁用文字降到 38%");
}
/// 状态层只在三态存在,且**不是**满档角色色(α 由 `interaction` 给)。
#[test]
fn state_layer_only_exists_on_lifted_states() {
let ui = ui();
for (state, expected) in [
(Interaction::Enabled, false),
(Interaction::Hovered, true),
(Interaction::Focused, true),
(Interaction::Pressed, true),
(Interaction::Disabled, false),
] {
let layer = paint::state_layer(&ui, state, Role::OnSurfaceVariant);
assert_eq!(layer.is_some(), expected, "{state:?} 的状态层有无");
if let Some(color) = layer {
assert_eq!(color.a, 1.0, "状态层必须是实色(D27:CPU/GPU 一致)");
}
}
// 同态下 hover 比 pressed 浅(8% < 10%)
let hover = paint::state_layer(&ui, Interaction::Hovered, Role::OnSurface).unwrap();
let pressed = paint::state_layer(&ui, Interaction::Pressed, Role::OnSurface).unwrap();
assert_ne!(hover, pressed);
}
@@ -0,0 +1,92 @@
//! Chip 角色与状态层的单测(M234 从 `selection/tests.rs` 纯搬移,守 400 行硬线)。
//!
//! 搬移零改动:判据与注释一字未动,只是**换文件**(`tests.rs` 加了 `.base(..)` 之后到 422 行)。
use super::*;
/// §13.5 / §13.6 / §13.7:选中态**无描边**、Assist 不可选中、禁用文字 @38%。
#[test]
fn chip_roles_follow_the_tokens() {
let ui = ui();
let radius = ChipGeometry::of(&ui).radius;
let selected = paint::chip(
&ui,
Base::CANVAS,
ChipVariant::Filter,
true,
Interaction::Enabled,
radius,
);
assert_eq!(
selected.frame.fill,
Some(ui.color(Role::SecondaryContainer))
);
assert_eq!(selected.frame.outline, None, "13.5:选中描边 0dp");
assert_eq!(selected.label, ui.color(Role::OnSecondaryContainer));
let unselected = paint::chip(
&ui,
Base::CANVAS,
ChipVariant::Filter,
false,
Interaction::Enabled,
radius,
);
assert_eq!(
unselected.frame.outline,
Some(ui.color(Role::OutlineVariant))
);
assert_eq!(unselected.label, ui.color(Role::OnSurfaceVariant));
// Assist 没有选中态:即使传 selected = true 也不会变 SecondaryContainer
let assist = paint::chip(
&ui,
Base::CANVAS,
ChipVariant::Assist,
true,
Interaction::Enabled,
radius,
);
assert_ne!(assist.frame.fill, Some(ui.color(Role::SecondaryContainer)));
assert_eq!(
assist.icon,
ui.color(Role::Primary),
"13.4:Assist 前置图标 Primary"
);
let disabled = paint::chip(
&ui,
Base::CANVAS,
ChipVariant::Filter,
false,
Interaction::Disabled,
radius,
);
assert_ne!(disabled.label, unselected.label, "13.7:禁用文字降到 38%");
}
/// 状态层只在三态存在,且**不是**满档角色色(α 由 `interaction` 给)。
#[test]
fn state_layer_only_exists_on_lifted_states() {
let ui = ui();
for (state, expected) in [
(Interaction::Enabled, false),
(Interaction::Hovered, true),
(Interaction::Focused, true),
(Interaction::Pressed, true),
(Interaction::Disabled, false),
] {
let layer = paint::state_layer(&ui, Base::CANVAS, state, Role::OnSurfaceVariant);
assert_eq!(layer.is_some(), expected, "{state:?} 的状态层有无");
if let Some(color) = layer {
assert_eq!(color.a, 1.0, "状态层必须是实色(D27:CPU/GPU 一致)");
}
}
// 同态下 hover 比 pressed 浅(8% < 10%)
let hover =
paint::state_layer(&ui, Base::CANVAS, Interaction::Hovered, Role::OnSurface).unwrap();
let pressed =
paint::state_layer(&ui, Base::CANVAS, Interaction::Pressed, Role::OnSurface).unwrap();
assert_ne!(hover, pressed);
}
@@ -31,6 +31,15 @@ use unlucky_design::typography::{TypeRole, TypeStyle, Weight};
use crate::theme::Ui;
use super::super::interaction::Base;
/// 卡片的底(**这个容器画什么,子控件的禁用态就叠什么**)。
///
/// 卡内控件的禁用色必须拿这个常量,不许自己写 `Role::Surface`:M234 之前 8 个调用点全传
/// 画布色,卡片里的禁用按钮于是把 10% 叠在画布上(M223 的「CR 1.05、形丢失」)。
/// 与 [`card`] 的 `background` 用同一个 `Role`,改色不会漂。
pub const BASE: Base = Base::of(Role::SurfaceContainer);
/// ELEVATED CARD 的内边距:**20**(= `LayoutMetrics::card_padding`)。
///
/// 为什么不是规格卡量到的 **24**(卡片文字 `x=88` − 框 `x=64`):本组件唯一的消费者是实例页的
@@ -63,7 +72,8 @@ pub fn card<'a, M: 'a>(ui: &'a Ui, content: impl Into<Element<'a, M>>) -> Elemen
.style(move |_theme| container::Style {
// 规格卡的卡底是 **surface-container**(不是 `surface-container-low`):
// 层级靠与画布(`surface`)的 tone 差表达,不靠描边。
background: Some(Background::Color(ui.color(Role::SurfaceContainer))),
// 子控件的禁用态拿同一个角色([`BASE`])合成,两边不会漂。
background: Some(Background::Color(ui.color(BASE.role()))),
border: Border {
radius: ui.radius_dp(shape),
..Border::default()
@@ -75,3 +75,18 @@ fn the_card_surface_sits_one_step_above_the_canvas() {
);
}
}
/// **M234**:卡片的底在代码里只出现两次——[`card`] 的 `background` 与导出的 [`BASE`],
/// 这两处必须同角色,否则卡里的禁用控件会合到另一层底上。
///
/// 抓得住:有人只改 `BASE`(或只改 `card`)导致两边漂开;抓不住:「`card()` 真的用了
/// `BASE` 画底」这件事(`container::Style` 闭包不可内省,只能靠代码审阅与
/// `gallery::probe` 的 disabledbase 段——那边的像素判据要求卡列里出现「叠卡片底」的禁用容器)。
#[test]
fn the_card_base_constant_is_the_role_the_card_paints() {
assert_eq!(
super::BASE.role(),
Role::SurfaceContainer,
"`elevated::BASE` 必须就是卡底的角色(`card()` 的 background 用的是同一个值)"
);
}
@@ -178,7 +178,8 @@ fn the_disabled_chip_still_looks_disabled() {
let disabled = Chip::<()>::filter("安装", true, ()).disabled().paint(ui);
let marker = Chip::<()>::current("默认").paint(ui);
let ink38 = interaction::disabled_content_over_role(ui, Role::OnSurface, false);
let ink38 =
interaction::disabled_content_over_role(ui, Role::Surface, Role::OnSurface, false);
let container12 = interaction::disabled_colors(ui, Role::Surface).0;
assert_eq!(