feat(app-iced): 禁用态合成底改成容器给的 Base(M234)

`interaction::disabled` 的四个入口一直收一个「底」,但 8 个调用点全传死
`Role::Surface`:卡片(SurfaceContainer)/ 面板(SurfaceContainerLow)里的禁用控件
把 10%(按钮族)/ 12%(面状控件族)叠在画布色上 —— M223「CR 1.05、形丢失」的根因。

- 新增 `interaction::Base`(画布 / 容器底的一等参数,`From<Role>` 兼容历史调用点);
  四个入口改成 `(ui, base, …)`,内部不再出现任何写死的 `Role::Surface`。
- 容器导出自己的 `BASE`:`surfaces::elevated::BASE`(卡)、`inputs::menu::PANEL_BASE`
  (菜单面板,项的 38% 与状态层都改叠它)、`navigation::drawer::BASE`。
- 控件收 `.base(..)`:Button / icon_on / Field / SearchBar / Slider / Checkbox / Radio /
  Switch / Chip / ListItem / Tabs;Fab 与 icon() 是画布默认值。
- `feedback/action.rs` 的本地 α 合成删掉,改走 `disabled_content_over_role` 并给真实容器底
  (Snackbar `InverseSurface` / Dialog `SurfaceContainerHigh`)。
- 判据:`interaction::disabled::tests`(卡片/面板/画布三档逐值)、
  `buttons::tests::base`(Button::base → StyleSpec → style_closure 接线)、
  `gallery` 新增 `disabledbase` 段 + `gallery::probe::bases` 精确像素断言
  (±10 容差区分不了 1/255 的差,所以自己数逐位相等的像素)。
- 变异自证:assemble 的调用点改回画布底后,上面两条都红(见 artifacts/m234/README.md)。

已知未尽:`crates/app-iced/src/pages/**` 不在 scope 内,页面侧「把控件放进卡片/面板」的
调用点尚未接 `.base(..)`(行为与本 mission 之前一致);`pages/…/java/tests/marker.rs`
有一行被迫更新(入口多了一个实参,语义不变 = 画布底)。
This commit is contained in:
AskaEth committed 2026-11-11 06:34:23 +08:00
1 parent a9d1cb4c44
commit 35dbefb08d
48 files changed
+1678 -484

No files matched your search

@@ -2,6 +2,9 @@
//! //!
//! 单独成文件的原因:`mod.rs` 已经是「对外契约 + 变体角色表」,把绘制细节放一起会顶到 400 行红线; //! 单独成文件的原因:`mod.rs` 已经是「对外契约 + 变体角色表」,把绘制细节放一起会顶到 400 行红线;
//! 而且这条产线是**唯一**会把「令牌 → 像素」翻一次的地方,独立出来才好审。 //! 而且这条产线是**唯一**会把「令牌 → 像素」翻一次的地方,独立出来才好审。
//!
//! 禁用态的合成底([`Base`],M234)也从这里往下传:`Button::base(..)` → [`StyleSpec::base`] →
//! `disabled_button_colors` / `disabled_button_content`。装配侧没有第二个猜底色的地方。
use iced::widget::text::LineHeight; use iced::widget::text::LineHeight;
use iced::widget::{button, text, Row}; use iced::widget::{button, text, Row};
@@ -14,9 +17,9 @@ use unlucky_design::shape::ShapeScale;
use crate::icons; use crate::icons;
use crate::theme::Ui; use crate::theme::Ui;
use super::super::interaction::{self, Interaction}; use super::super::interaction::{self, Base, Interaction};
use super::super::layout; use super::super::layout;
use super::{Geometry, Variant}; use super::{Button, Geometry, Variant};
/// 一个按钮样式要的全部输入(避免 `style_closure` 长出七个位置参数)。 /// 一个按钮样式要的全部输入(避免 `style_closure` 长出七个位置参数)。
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
@@ -33,6 +36,12 @@ pub(crate) struct StyleSpec {
pub stroke: f32, pub stroke: f32,
/// 圆角折算用的边长(药丸 = 可见高)。 /// 圆角折算用的边长(药丸 = 可见高)。
pub radius_side: 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`。 /// 强制态(gallery 取证);`None` = 跟随运行期的 `button::Status`。
/// ///
/// **`Some(Enabled)` 与 `None` 不是一回事**:截图取证必须能强制「静息」,否则光标恰好停在 /// **`Some(Enabled)` 与 `None` 不是一回事**:截图取证必须能强制「静息」,否则光标恰好停在
@@ -45,15 +54,11 @@ pub(crate) struct StyleSpec {
pub(super) fn assemble<'a, M: Clone + 'a>( pub(super) fn assemble<'a, M: Clone + 'a>(
ui: &'a Ui, ui: &'a Ui,
variant: Variant, variant: Variant,
spec: super::Button<'a, M>, spec: Button<'a, M>,
) -> Element<'a, M> { ) -> Element<'a, M> {
let geometry = Geometry::sized(ui, variant, spec.leading.is_some(), spec.size); let geometry = Geometry::sized(ui, variant, spec.leading.is_some(), spec.size);
let (fill, content, border) = variant.roles(); let (fill, content, _border) = variant.roles();
let forced = spec.state.or(if spec.on_press.is_none() { let forced = spec.forced();
Some(Interaction::Disabled)
} else {
None
});
// 按钮家族没有浮层级高程:L1(Elevated)只用容器色调差表达(rubric §7 不画阴影) // 按钮家族没有浮层级高程:L1(Elevated)只用容器色调差表达(rubric §7 不画阴影)
debug_assert!( debug_assert!(
@@ -67,7 +72,14 @@ pub(super) fn assemble<'a, M: Clone + 'a>(
// 早先这里恒取 `ui.color(content)`,再被 `text(...).color(..)` 钉死,`Style.text_color` // 早先这里恒取 `ui.color(content)`,再被 `text(...).color(..)` 钉死,`Style.text_color`
// 里的 38% 永远下发不出去 —— 表现为「text / standard icon 的 enabled 格与 disabled 格 // 里的 38% 永远下发不出去 —— 表现为「text / standard icon 的 enabled 格与 disabled 格
// 逐像素相同」(视觉 reviewer 实测 changed_px = 0;filled 禁用标签 1.23:1)。 // 逐像素相同」(视觉 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」定死 // 标签排版跟尺寸档走:`ControlSize::label()` 已把「字号跟字级、字重固定 500」定死
// (XS/S = LabelLarge 14、M = BodyLarge 16、L = HeadlineSmall 24、XL = HeadlineLarge 32)。 // (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) .height(Length::Fill)
.align_y(iced::Alignment::Center); .align_y(iced::Alignment::Center);
let style = style_closure( let style = style_closure(ui, text_style_spec(ui, variant, &spec));
ui,
StyleSpec {
fill,
content,
border,
shape: variant.shape(),
stroke: geometry.stroke,
radius_side: geometry.height,
forced,
},
);
let element = button(content_box) let element = button(content_box)
.height(Length::Fixed(geometry.height)) .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%,其余态就是角色色。 /// 内容色(文字与图标共用):禁用态走令牌的 38%,其余态就是角色色。
/// ///
/// 单独成函数是为了让「内容色跟态走」这件事只有一处实现——`assemble`(文字按钮)与 `icon` /// 单独成函数是为了让「内容色跟态走」这件事只有一处实现——`assemble`(文字按钮)与 `icon`
@@ -139,19 +160,20 @@ pub(super) fn assemble<'a, M: Clone + 'a>(
/// 其余是 `OnSurfaceVariant`,不能在这里统一写死。 /// 其余是 `OnSurfaceVariant`,不能在这里统一写死。
pub(super) fn content_color( pub(super) fn content_color(
ui: &Ui, ui: &Ui,
base: Base,
forced: Option<Interaction>, forced: Option<Interaction>,
content: Role, content: Role,
fill: Option<Role>, fill: Option<Role>,
disabled_content: Role, disabled_content: Role,
) -> iced::Color { ) -> iced::Color {
match forced { match forced {
// 有容器:38% 叠在**按钮家族 10% 容器**上(Filled / Tonal / Elevated) // 有容器:38% 叠在**按钮家族 10% 容器**上(Filled / Tonal / Elevated),容器再叠在 `base` 上
Some(Interaction::Disabled) if fill.is_some() => { 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) => { Some(Interaction::Disabled) => {
interaction::disabled_button_content(ui, disabled_content, false) interaction::disabled_button_content(ui, base, disabled_content, false)
} }
_ => ui.color(content), _ => ui.color(content),
} }
@@ -168,11 +190,17 @@ pub(super) fn style_closure(
.forced .forced
.unwrap_or_else(|| Interaction::from_status(status)); .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); 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) (spec.fill.map(|_| container), content)
} else { }
None => {
// 有填充:状态层叠在**容器角色**上;透明容器:叠在表面色上(否则 hover 看不见) // 有填充:状态层叠在**容器角色**上;透明容器:叠在表面色上(否则 hover 看不见)
let background = match spec.fill { let background = match spec.fill {
Some(role) => match state.layer() { Some(role) => match state.layer() {
@@ -183,13 +211,14 @@ pub(super) fn style_closure(
None => transparent_state_color(ui, state, spec.content), None => transparent_state_color(ui, state, spec.content),
}; };
(background, ui.color(spec.content)) (background, ui.color(spec.content))
}
}; };
let border_color = if state.is_disabled() { let border_color = match disabled {
interaction::disabled_button_colors(ui, Role::Surface).0 Some((container, _content)) => container,
} else { None => spec
spec.border .border
.map_or_else(|| ui.color(Role::OutlineVariant), |role| ui.color(role)) .map_or_else(|| ui.color(Role::OutlineVariant), |role| ui.color(role)),
}; };
button::Style { button::Style {
@@ -35,7 +35,7 @@ use unlucky_design::typography::{TypeRole, TypeStyle, Weight};
use crate::icons; use crate::icons;
use crate::theme::Ui; use crate::theme::Ui;
use super::super::interaction::{self, Interaction}; use super::super::interaction::{self, Base, Interaction};
use super::super::layout; use super::super::layout;
/// 扩展态右侧内距(px)。规格卡只量到起始 28(`FabSize::pad_start_px`),右侧没有量; /// 扩展态右侧内距(px)。规格卡只量到起始 28(`FabSize::pad_start_px`),右侧没有量;
@@ -97,7 +97,7 @@ pub fn fab<'a, M: Clone + 'a>(ui: &'a Ui, spec: Fab<'a, M>) -> Element<'a, M> {
let ink = match forced { let ink = match forced {
// 禁用:38% 的内容色叠在容器上(与按钮族的容器档同一口径) // 禁用:38% 的内容色叠在容器上(与按钮族的容器档同一口径)
Some(Interaction::Disabled) => { Some(Interaction::Disabled) => {
interaction::disabled_button_content(ui, Role::OnPrimaryContainer, true) interaction::disabled_button_content(ui, Base::CANVAS, Role::OnPrimaryContainer, true)
} }
_ => ui.color(Role::OnPrimaryContainer), _ => ui.color(Role::OnPrimaryContainer),
}; };
@@ -160,7 +160,7 @@ pub fn fab<'a, M: Clone + 'a>(ui: &'a Ui, spec: Fab<'a, M>) -> Element<'a, M> {
None => Some(ui.color(Role::PrimaryContainer)), None => Some(ui.color(Role::PrimaryContainer)),
}; };
let text_color = if state.is_disabled() { let text_color = if state.is_disabled() {
interaction::disabled_button_content(ui, Role::OnPrimaryContainer, true) interaction::disabled_button_content(ui, Base::CANVAS, Role::OnPrimaryContainer, true)
} else { } else {
ui.color(Role::OnPrimaryContainer) 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::icons;
use crate::theme::Ui; use crate::theme::Ui;
use super::interaction::Interaction; use super::interaction::{Base, Interaction};
use super::layout; use super::layout;
mod assemble; mod assemble;
mod fab; mod fab;
mod geometry; mod geometry;
mod icon_variant;
pub use fab::{fab, Fab}; pub use fab::{fab, Fab};
pub use geometry::{Geometry, IconGeometry}; pub use geometry::{Geometry, IconGeometry};
pub use icon_variant::IconVariant;
use assemble::{assemble, style_closure, StyleSpec}; 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` = 禁用, /// `on_press: Option<M>` 就是 M3 的 enabled/disabled 开关——`None` = 禁用,
@@ -238,6 +179,8 @@ pub struct Button<'a, M> {
state: Option<Interaction>, state: Option<Interaction>,
/// 尺寸档(规格卡 `BUTTONS / XS - XL`)。产品默认 **S(40 高)**,与 M3 `control_height` 同值。 /// 尺寸档(规格卡 `BUTTONS / XS - XL`)。产品默认 **S(40 高)**,与 M3 `control_height` 同值。
size: ControlSize, size: ControlSize,
/// **脚下那一层的底**(M234):禁用态的 10% 容器与 38% 内容叠在它上面。
base: Base,
} }
impl<'a, M> Button<'a, M> { impl<'a, M> Button<'a, M> {
@@ -250,6 +193,7 @@ impl<'a, M> Button<'a, M> {
trailing: None, trailing: None,
state: None, state: None,
size: ControlSize::S, size: ControlSize::S,
base: Base::CANVAS,
} }
} }
@@ -262,6 +206,7 @@ impl<'a, M> Button<'a, M> {
trailing: None, trailing: None,
state: None, state: None,
size: ControlSize::S, size: ControlSize::S,
base: Base::CANVAS,
} }
} }
@@ -291,6 +236,28 @@ impl<'a, M> Button<'a, M> {
self.state = Some(state); self.state = Some(state);
self 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`)。 /// Filled 按钮(实心 `Primary`)。
@@ -337,6 +304,7 @@ pub(crate) fn icon_style_spec(
variant: IconVariant, variant: IconVariant,
side: f32, side: f32,
forced: Option<Interaction>, forced: Option<Interaction>,
base: Base,
) -> StyleSpec { ) -> StyleSpec {
let (fill, content, border) = variant.roles(); let (fill, content, border) = variant.roles();
StyleSpec { StyleSpec {
@@ -348,6 +316,7 @@ pub(crate) fn icon_style_spec(
// M229 复审 P1-1:这里曾硬编码 `0.0`,于是 `Outlined` 与 `Plain` 逐像素相同。 // M229 复审 P1-1:这里曾硬编码 `0.0`,于是 `Outlined` 与 `Plain` 逐像素相同。
stroke: icon_stroke(variant), stroke: icon_stroke(variant),
radius_side: side, radius_side: side,
base,
forced, forced,
} }
} }
@@ -360,6 +329,8 @@ pub(crate) fn icon_style_spec(
/// gallery 的样例因此统一用描边图标(`Download` + `ChevronRight`),并有单测钉住。 /// gallery 的样例因此统一用描边图标(`Download` + `ChevronRight`),并有单测钉住。
/// ///
/// `on_press: Option<M>`(`None` = 禁用)与 `state` 分开给:前者是语义,后者是取证开关。 /// `on_press: Option<M>`(`None` = 禁用)与 `state` 分开给:前者是语义,后者是取证开关。
///
/// 这是 **画布版**(禁用底 = [`Base::CANVAS`]):放进卡片/面板里请用 [`icon_on`] 给真实父底。
pub fn icon<'a, M: Clone + 'a>( pub fn icon<'a, M: Clone + 'a>(
ui: &'a Ui, ui: &'a Ui,
variant: IconVariant, variant: IconVariant,
@@ -367,6 +338,20 @@ pub fn icon<'a, M: Clone + 'a>(
on_press: Option<M>, on_press: Option<M>,
state: Option<Interaction>, state: Option<Interaction>,
) -> Element<'a, M> { ) -> 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 geometry = IconGeometry::of(ui);
let (fill, content, _) = variant.roles(); let (fill, content, _) = variant.roles();
let forced = state.or(if on_press.is_some() { 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` 把内容放左上角、不居中, // 这里把盒子做成整个 40×40(而不是 24×24):`button::layout` 把内容放左上角、不居中,
// 所以「居中」这件事必须由容器自己写死,别指望 button。 // 所以「居中」这件事必须由容器自己写死,别指望 button。
// 内容色跟态走(禁用 = on-surface@38%),见 `assemble::content_color` 的说明 // 内容色跟态走(禁用 = 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 = let icon_box =
layout::centered_fixed(geometry.side, geometry.side, icons::view::<M>(kind, glyph)); 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) let element = button(icon_box)
.width(Length::Fixed(geometry.side)) .width(Length::Fixed(geometry.side))
@@ -11,7 +11,7 @@ use unlucky_design::elevation::Elevation;
use unlucky_design::icons::IconKind; use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space; use unlucky_design::spacing::Space;
use super::super::interaction::{self, Interaction}; use super::super::interaction::{self, Base, Interaction};
use super::assemble::StyleSpec; use super::assemble::StyleSpec;
use super::{style_closure, Geometry, IconGeometry, IconVariant, Variant}; use super::{style_closure, Geometry, IconGeometry, IconVariant, Variant};
use crate::theme::{Families, Ui, STROKE_PX}; use crate::theme::{Families, Ui, STROKE_PX};
@@ -122,6 +122,7 @@ fn icon_button_geometry_matches_the_reference() {
); );
} }
mod base;
mod icon; mod icon;
/// 视觉 reviewer P2-3:同一控件里不许混用填充字形与描边字形(线宽语汇会打架)。 /// 视觉 reviewer P2-3:同一控件里不许混用填充字形与描边字形(线宽语汇会打架)。
@@ -255,6 +256,7 @@ fn spec_of(variant: Variant, forced: Option<Interaction>) -> StyleSpec {
content, content,
border, border,
shape: variant.shape(), shape: variant.shape(),
base: Base::CANVAS,
stroke: Geometry::of(&light(), variant, false).stroke, stroke: Geometry::of(&light(), variant, false).stroke,
radius_side: Geometry::of(&light(), variant, false).height, radius_side: Geometry::of(&light(), variant, false).height,
forced, forced,
@@ -283,8 +285,7 @@ fn outlined_style_draws_hairline_border_and_full_radius() {
assert_ne!(hovered.background, idle.background); assert_ne!(hovered.background, idle.background);
let disabled = style(&ui.theme(), button::Status::Disabled); let disabled = style(&ui.theme(), button::Status::Disabled);
let (container, content) = let (container, content) = interaction::disabled_button_colors(&ui, Base::CANVAS);
interaction::disabled_button_colors(&ui, unlucky_design::color::Role::Surface);
assert_eq!(disabled.text_color, content, "禁用内容 = 令牌 38%"); assert_eq!(disabled.text_color, content, "禁用内容 = 令牌 38%");
assert_eq!( assert_eq!(
disabled.border.color, container, 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()` 真正塞进 `style_closure` 的那个结构体(`icon_style_spec`)。
// 只测 `icon_stroke()` 抓不住「调用点写死 0」——第一版就是这样漏的(变异测试证明过)。 // 只测 `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!( assert_eq!(
spec.stroke, spec.stroke,
icon_stroke(variant), icon_stroke(variant),
@@ -20,7 +20,6 @@
use iced::widget::{button, text}; use iced::widget::{button, text};
use iced::{Background, Border, Element, Length, Padding, Shadow}; use iced::{Background, Border, Element, Length, Padding, Shadow};
use unlucky_design::color::state::DISABLED_CONTENT_ALPHA;
use unlucky_design::color::Role; use unlucky_design::color::Role;
use unlucky_design::component_map::Component; use unlucky_design::component_map::Component;
use unlucky_design::icons::IconKind; use unlucky_design::icons::IconKind;
@@ -28,7 +27,7 @@ use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole; use unlucky_design::typography::TypeRole;
use crate::icons; use crate::icons;
use crate::theme::{to_iced, Ui}; use crate::theme::Ui;
use super::super::interaction::{self, Interaction}; use super::super::interaction::{self, Interaction};
use super::super::layout; use super::super::layout;
@@ -43,13 +42,14 @@ fn content_color(ui: &Ui, role: Role, container_role: Role, disabled: bool) -> i
if !disabled { if !disabled {
return ui.color(role); return ui.color(role);
} }
// 取 `Ui` 的当前方案(品牌板 / 取色方案),不重算 baseline(复审 F1)。 // M234:`disabled_content_over_role` 现在收**任意**底(`Base`),所以这里的本地合成可以删掉
let scheme = ui.scheme(); // ——底就是这两个动作件自己的容器角色(Snackbar = InverseSurface / Dialog = SurfaceContainerHigh),
to_iced( // α 与叠法仍由 `interaction::disabled` 那一处给(本文件零 α 字面量)。
scheme interaction::disabled_content_over_role(
.get(Role::OnSurface) ui,
.with_alpha(DISABLED_CONTENT_ALPHA) interaction::Base::of(container_role),
.over(scheme.get(container_role)), Role::OnSurface,
false,
) )
} }
@@ -11,7 +11,7 @@ use unlucky_design::typography::Platform;
use crate::theme::{to_iced, Families, Ui}; 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::dialog::scrim;
use super::panel::{DialogGeometry, EmptyStateGeometry, SnackbarGeometry}; use super::panel::{DialogGeometry, EmptyStateGeometry, SnackbarGeometry};
use super::progress::Progress; use super::progress::Progress;
@@ -235,7 +235,8 @@ fn snackbar_state_layers_come_from_the_interaction_module() {
); );
// 禁用内容走 token 口径(38%),不是「变灰一点」 // 禁用内容走 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_ne!(disabled, ui.color(Role::OnSurface));
assert_eq!(disabled.a, 1.0); assert_eq!(disabled.a, 1.0);
} }
@@ -0,0 +1,212 @@
//! 禁用态的**合成底**(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/base.rs`)
//!
//! 像素判据判的是**精确值**:卡列里的禁用按钮容器必须等于 `on-surface@10% 叠卡片底`,
//! 且**不等于**画布底那一档。注意量级:Ink/Silver 板上 `Surface` 与 `SurfaceContainer`
//! 只差个位数 RGB,10% 之后差 **≈1/255**——肉眼看不出来,所以这张图的用处是**取证**,
//! 不是「看出区别」。真判据在 `interaction::disabled::tests`(逐值)与 `probe/base.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; use super::layout;
mod buttons; mod buttons;
mod disabledbase;
mod feedback; mod feedback;
mod inputs; mod inputs;
mod navigation; mod navigation;
@@ -97,6 +98,7 @@ pub const SECTION_ENV: &str = "ULL_GALLERY_SECTION";
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Sections { struct Sections {
buttons: bool, buttons: bool,
disabledbase: bool,
sizes: bool, sizes: bool,
divider: bool, divider: bool,
selection: bool, selection: bool,
@@ -109,6 +111,7 @@ impl Sections {
/// 七段全渲染(默认)。 /// 七段全渲染(默认)。
const ALL: Self = Self { const ALL: Self = Self {
buttons: true, buttons: true,
disabledbase: true,
sizes: true, sizes: true,
divider: true, divider: true,
selection: true, selection: true,
@@ -121,6 +124,7 @@ impl Sections {
fn only(name: &str) -> Option<Self> { fn only(name: &str) -> Option<Self> {
let mut sections = Self { let mut sections = Self {
buttons: false, buttons: false,
disabledbase: false,
sizes: false, sizes: false,
divider: false, divider: false,
selection: false, selection: false,
@@ -130,6 +134,7 @@ impl Sections {
}; };
match name { match name {
"buttons" => sections.buttons = true, "buttons" => sections.buttons = true,
"disabledbase" => sections.disabledbase = true,
"sizes" => sections.sizes = true, "sizes" => sections.sizes = true,
"divider" => sections.divider = true, "divider" => sections.divider = true,
"selection" => sections.selection = true, "selection" => sections.selection = true,
@@ -156,7 +161,7 @@ impl Sections {
return sections; return sections;
} }
eprintln!( 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 Self::ALL
} }
@@ -166,6 +171,7 @@ impl Sections {
let mut names = Vec::new(); let mut names = Vec::new();
for (on, name) in [ for (on, name) in [
(self.buttons, "buttons"), (self.buttons, "buttons"),
(self.disabledbase, "disabledbase"),
(self.sizes, "sizes"), (self.sizes, "sizes"),
(self.divider, "divider"), (self.divider, "divider"),
(self.selection, "selection"), (self.selection, "selection"),
@@ -244,6 +250,9 @@ pub fn view<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
if picked.buttons { if picked.buttons {
sections = sections.push(buttons::section(ui)); sections = sections.push(buttons::section(ui));
} }
if picked.disabledbase {
sections = sections.push(disabledbase::section(ui));
}
if picked.sizes { if picked.sizes {
sections = sections.push(sizes::section(ui)); sections = sections.push(sizes::section(ui));
} }
@@ -36,10 +36,11 @@ use unlucky_design::typography::Platform;
use crate::theme::{Families, Ui}; use crate::theme::{Families, Ui};
use crate::app::components::interaction; use crate::app::components::interaction::{self, Base};
use super::scan::{matches, nearer, rgb, Shot}; use super::scan::{matches, nearer, rgb, Shot};
mod bases;
mod rows; mod rows;
const FAMILIES: Families = Families { const FAMILIES: Families = Families {
@@ -146,14 +147,19 @@ fn gallery_geometry_matches_the_tokens() {
.trim() .trim()
.to_ascii_lowercase(); .to_ascii_lowercase();
assert!( assert!(
section == "buttons" || section == "divider", section == "buttons" || section == "divider" || section == "disabledbase",
"这份 probe 只支持 `ULL_GALLERY_SECTION=buttons`(按钮矩阵)或 `divider`(分隔线);当前 {section:?}。`all` 不行:整页比视口高,分隔线段与按钮矩阵不可能同屏。" "这份 probe 只支持 `ULL_GALLERY_SECTION=buttons`(按钮矩阵)/ `divider`(分隔线)/ `disabledbase`(禁用态合成底);当前 {section:?}。`all` 不行:整页比视口高,分隔线段与按钮矩阵不可能同屏。"
); );
// 分隔线那段是**独立出口**:`divider` 段的 sheet 里没有按钮矩阵,判据也不同。 // 分隔线那段是**独立出口**:`divider` 段的 sheet 里没有按钮矩阵,判据也不同。
if section == "divider" { if section == "divider" {
check_divider(&shot, &ui); check_divider(&shot, &ui);
return; return;
} }
// 禁用态合成底(M234):三块板各按自己的底合成,判据在 `probe/base.rs`。
if section == "disabledbase" {
bases::check(&shot, &ui, flavor);
return;
}
// ---- 12b 1.1 / 1.2:实心按钮 40 高、药丸圆角 20 ------------------------------ // ---- 12b 1.1 / 1.2:实心按钮 40 高、药丸圆角 20 ------------------------------
let primary = shot.bbox_of(rgb(ui.color(Role::Primary))); 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%。 // **按钮族**:容器 10%(官方 `*ButtonTokens.DisabledContainerOpacity = 0.1f`)、内容 38%。
// M86 之前这里和 chip 共用同一个 12% 常量,所以断言与实现「一起错」、永远同源——拆族后才对得上。 // M86 之前这里和 chip 共用同一个 12% 常量,所以断言与实现「一起错」、永远同源——拆族后才对得上。
let (disabled_container, _) = interaction::disabled_button_colors(&ui, Role::Surface); let (disabled_container, _) = interaction::disabled_button_colors(&ui, Base::CANVAS);
assert!( assert!(
shot.bbox_of(rgb(disabled_container)) shot.bbox_of(rgb(disabled_container))
.iter() .iter()
@@ -0,0 +1,216 @@
//! `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;
let hits = count_exact(shot, wanted_button, x0, x1, y0, y1);
assert!(
hits >= MIN_BUTTON_PX,
"{name} 列里「叠 {:?}」的禁用按钮容器只有 {hits} px(应 ≥{MIN_BUTTON_PX}):\
这一列的禁用控件没把真实父底传下去",
base.role()
);
let hits = count_exact(shot, wanted_field, x0, x1, y0, y1);
assert!(
hits >= MIN_FIELD_PX,
"{name} 列里「叠 {:?}」的禁用字段容器只有 {hits} px(应 ≥{MIN_FIELD_PX})",
base.role()
);
// 反向:这一列里不许出现「叠画布底」的那一档
let stray = count_exact(shot, canvas_button, x0, x1, y0, y1);
assert!(
hits > stray * STRAY_RATIO,
"{name} 列里「叠 {:?}」的禁用按钮容器只有 {hits} px,而「叠画布」的有 {stray} px (要求前者 ≥ 后者 ×{STRAY_RATIO})—— 这一列的控件仍在按 Role::Surface 合成",
base.role()
);
let stray = count_exact(shot, canvas_field, x0, x1, y0, y1);
assert!(
hits > stray * STRAY_RATIO,
"{name} 列里「叠 {:?}」的禁用字段容器只有 {hits} px,而「叠画布」的有 {stray} px (要求前者 ≥ 后者 ×{STRAY_RATIO})",
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:?}:\
canvas 按钮 {:?} / 卡片 {} / 面板 {} ⇒ 三列各按自己的底合成",
canvas_button, card_rect.0, panel_rect.0
);
}
@@ -7,6 +7,7 @@ use unlucky_design::color::Role;
use super::super::scan::{rgb, Shot}; use super::super::scan::{rgb, Shot};
use crate::app::components::interaction; use crate::app::components::interaction;
use crate::app::components::interaction::Base;
use crate::theme::Ui; use crate::theme::Ui;
/// 矩阵行距(40 高按钮 + 8 间距)。 /// 矩阵行距(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; let y1 = y0 + ROW_HEIGHT;
// **与实现同源**:按钮族的内容色一律走 `disabled_button_content`(38% 叠在 10% 底上)。 // **与实现同源**:按钮族的内容色一律走 `disabled_button_content`(38% 叠在 10% 底上)。
// 用面状控件族的 12% 底会让断言比实现宽一档,差的 1/255 会放跑「容器对内容错」。 // 用面状控件族的 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); let count = shot.count_in(rgb(ink), column_from, shot.width, y0, y1);
// 阈值 **30**:最窄的一行是只有 4 个字符的 `text` 变体(实测 38 px 墨迹,M229 复审后 // 阈值 **30**:最窄的一行是只有 4 个字符的 `text` 变体(实测 38 px 墨迹,M229 复审后
// 按钮内距 24→20 让标签左移,余量只剩 2 px)。角色接错时会掉到个位数甚至 0。 // 按钮内距 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::widget::{column, container, row, text, text_input};
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels}; use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind; use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space; use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole; use unlucky_design::typography::TypeRole;
@@ -30,10 +29,16 @@ use unlucky_design::typography::TypeRole;
use crate::icons; use crate::icons;
use crate::theme::Ui; 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::super::layout;
use super::geometry::FieldGeometry; use super::geometry::FieldGeometry;
mod paint;
use paint::palette;
/// 两式容器(12b 9.3 / 9.4 的差别:Filled 有容器色 + 底部指示线;Outlined 只有描边)。 /// 两式容器(12b 9.3 / 9.4 的差别:Filled 有容器色 + 底部指示线;Outlined 只有描边)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FieldVariant { pub enum FieldVariant {
@@ -70,6 +75,8 @@ pub struct Field<'a> {
state: Option<Interaction>, state: Option<Interaction>,
width: Length, width: Length,
secure: bool, secure: bool,
/// 脚下那一层的底(M234,禁用态合成用)。
base: Base,
} }
impl<'a> Field<'a> { impl<'a> Field<'a> {
@@ -87,9 +94,19 @@ impl<'a> Field<'a> {
state: None, state: None,
width: Length::Fill, width: Length::Fill,
secure: false, 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)。 /// 选式(Filled / Outlined)。
pub fn variant(mut self, variant: FieldVariant) -> Self { pub fn variant(mut self, variant: FieldVariant) -> Self {
self.variant = variant; 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`] 选)。 /// 一个文本框(Filled / Outlined 由 [`Field::variant`] 选)。
/// ///
/// 受控:值由外部持有,输入经 `on_input` 回传;`state` 强制成 `Disabled` 时输入被摘掉 /// 受控:值由外部持有,输入经 `on_input` 回传;`state` 强制成 `Disabled` 时输入被摘掉
@@ -262,7 +179,7 @@ pub fn text_field<'a, M: Clone + 'a>(
) -> Element<'a, M> { ) -> Element<'a, M> {
let geometry = FieldGeometry::of(ui, field.label.is_some(), field.leading); let geometry = FieldGeometry::of(ui, field.label.is_some(), field.leading);
let state = field.state.unwrap_or(Interaction::Enabled); 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 input_style = ui.style(TypeRole::BodyLarge);
let label_style = ui.style(TypeRole::BodySmall); 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)。 //! field 的单测(从 `field.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。
use super::*; use super::*;
use unlucky_design::color::Role;
use unlucky_design::color::SchemeFlavor; use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform; use unlucky_design::typography::Platform;
@@ -44,23 +45,47 @@ fn every_variant_and_state_builds() {
#[test] #[test]
fn outline_colors_and_widths_follow_the_state() { fn outline_colors_and_widths_follow_the_state() {
let ui = ui(SchemeFlavor::Light); 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.line, ui.color(Role::Outline));
assert_eq!(rest.stroke, 1.0); assert_eq!(rest.stroke, 1.0);
assert_eq!(rest.fill, None, "Outlined 无容器色"); 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.line, ui.color(Role::OnSurface));
assert_eq!(hover.stroke, 1.0); 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.line, ui.color(Role::Primary));
assert_eq!(focus.stroke, 2.0, "FocusOutlineWidth = 2dp"); assert_eq!(focus.stroke, 2.0, "FocusOutlineWidth = 2dp");
assert_eq!(focus.label, ui.color(Role::Primary), "FocusLabelColor"); assert_eq!(focus.label, ui.color(Role::Primary), "FocusLabelColor");
// A1 没有 Pressed token:按 hover 处理(D11),不是漏做 // A1 没有 Pressed token:按 hover 处理(D11),不是漏做
assert_eq!( assert_eq!(
palette(&ui, FieldVariant::Outlined, Interaction::Pressed, false), palette(
&ui,
Base::CANVAS,
FieldVariant::Outlined,
Interaction::Pressed,
false
),
hover hover
); );
} }
@@ -69,7 +94,13 @@ fn outline_colors_and_widths_follow_the_state() {
#[test] #[test]
fn error_beats_focus_and_paints_everything_error() { fn error_beats_focus_and_paints_everything_error() {
let ui = ui(SchemeFlavor::Light); 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.line, ui.color(Role::Error));
assert_eq!(error.label, ui.color(Role::Error)); assert_eq!(error.label, ui.color(Role::Error));
assert_eq!(error.supporting, 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() { fn disabled_uses_the_token_alphas() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let ui = ui(flavor); 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.fill, Some(container));
assert_eq!( assert_eq!(
filled.input, 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.fill, None);
assert_eq!(outlined.line, container, "DisabledOutlineOpacity = 0.12"); assert_eq!(outlined.line, container, "DisabledOutlineOpacity = 0.12");
assert_eq!( assert_eq!(
outlined.input, 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), "禁用不许满档"); assert_ne!(outlined.input, ui.color(Role::OnSurface), "禁用不许满档");
} }
@@ -107,7 +150,14 @@ fn disabled_uses_the_token_alphas() {
fn filled_carries_the_container_role() { fn filled_carries_the_container_role() {
let ui = ui(SchemeFlavor::Light); let ui = ui(SchemeFlavor::Light);
assert_eq!( 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)), Some(ui.color(Role::SurfaceContainerHighest)),
"FilledTextFieldTokens.ContainerColor" "FilledTextFieldTokens.ContainerColor"
); );
+22 -10
View File
@@ -42,7 +42,7 @@ use crate::icons;
use crate::theme::Ui; use crate::theme::Ui;
use super::super::divider; use super::super::divider;
use super::super::interaction::{self, Interaction}; use super::super::interaction::{self, Base, Interaction};
use super::super::layout; use super::super::layout;
use super::geometry::MenuGeometry; use super::geometry::MenuGeometry;
@@ -171,10 +171,10 @@ struct ItemPalette {
} }
/// 解析一项的颜色:选中优先,其余按五态叠状态层(`interaction::overlay_color`)。 /// 解析一项的颜色:选中优先,其余按五态叠状态层(`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() { if state.is_disabled() {
// 禁用:内容 38%(无容器时叠在表面色上;菜单项本身透明) // 禁用:内容 38%,叠在**菜单面板的底**上(项本身透明,透出来的就是面板色)
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 { return ItemPalette {
fill: None, fill: None,
ink, ink,
@@ -201,14 +201,20 @@ fn item_palette(ui: &Ui, state: Interaction, selected: bool) -> ItemPalette {
} }
ItemPalette { ItemPalette {
// 状态层叠在**菜单容器色**上(SurfaceContainer),得到不透明结果(D27) // 状态层叠在**菜单容器色**上(`PANEL_BASE` = SurfaceContainer),得到不透明结果(D27)
fill: interaction::overlay_color(ui, state, Role::OnSurface, Role::SurfaceContainer), fill: interaction::overlay_color(ui, state, Role::OnSurface, base.role()),
ink: ui.color(Role::OnSurface), ink: ui.color(Role::OnSurface),
icon: ui.color(Role::OnSecondaryContainer), icon: ui.color(Role::OnSecondaryContainer),
shape: ShapeScale::Small, 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 阴影)。 /// 菜单弹出层(`SurfaceContainer` + L2 阴影)。
pub fn menu<'a, M: Clone + 'a>(ui: &'a Ui, menu: Menu<'a, M>) -> Element<'a, M> { pub fn menu<'a, M: Clone + 'a>(ui: &'a Ui, menu: Menu<'a, M>) -> Element<'a, M> {
let geometry = MenuGeometry::of(ui); 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" "MenuTokens.ContainerElevation"
); );
let shadow_spec = elevation.shadow(); let shadow_spec = elevation.shadow();
let panel = ui.color(Role::SurfaceContainer); let panel = ui.color(PANEL_BASE.role());
let shape = geometry.shape; let shape = geometry.shape;
let mut rows = column![].width(Length::Fill); let mut rows = column![].width(Length::Fill);
for entry in menu.entries { for entry in menu.entries {
rows = rows.push(match entry { 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),不自己画 // 分组分隔线:复用 `divider::full`(一像素线 + OutlineVariant),不自己画
Entry::Divider => divider::full::<M>(ui), 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` 承载命中与四态,几何与颜色自绘)。 /// 一行菜单项(用 `button` 承载命中与四态,几何与颜色自绘)。
fn item_row<'a, M: Clone + 'a>( fn item_row<'a, M: Clone + 'a>(
ui: &'a Ui, ui: &'a Ui,
base: Base,
item: MenuItem<'a, M>, item: MenuItem<'a, M>,
geometry: MenuGeometry, geometry: MenuGeometry,
) -> Element<'a, M> { ) -> Element<'a, M> {
@@ -265,7 +272,12 @@ fn item_row<'a, M: Clone + 'a>(
None None
}); });
// 选中项的背景也要跟态走:禁用时不许留满档 SecondaryContainer // 选中项的背景也要跟态走:禁用时不许留满档 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 body = ui.style(TypeRole::BodyMedium);
let label = text(item.label) let label = text(item.label)
@@ -308,7 +320,7 @@ fn item_row<'a, M: Clone + 'a>(
let selected = item.selected; let selected = item.selected;
let style = move |_theme: &iced::Theme, status: button::Status| { let style = move |_theme: &iced::Theme, status: button::Status| {
let state = forced.unwrap_or_else(|| Interaction::from_status(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 { button::Style {
background: colors.fill.map(Background::Color), background: colors.fill.map(Background::Color),
text_color: colors.ink, text_color: colors.ink,
@@ -55,13 +55,13 @@ fn container_uses_surface_container_and_level2_shadow() {
#[test] #[test]
fn selected_and_disabled_items_use_the_right_roles() { fn selected_and_disabled_items_use_the_right_roles() {
let ui = ui(SchemeFlavor::Light); 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.fill, Some(ui.color(Role::SecondaryContainer)));
assert_eq!(selected.ink, ui.color(Role::OnSecondaryContainer)); assert_eq!(selected.ink, ui.color(Role::OnSecondaryContainer));
assert_eq!(selected.icon, ui.color(Role::OnSecondaryContainer)); assert_eq!(selected.icon, ui.color(Role::OnSecondaryContainer));
// 选中 + hover:状态层叠在 SecondaryContainer 上(不是满档底色) // 选中 + 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_ne!(hovered.fill, selected.fill);
assert_eq!( assert_eq!(
hovered.fill, 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.fill, None, "禁用项不留满档选中底色");
assert_eq!( assert_eq!(
disabled.ink, 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)); assert_ne!(disabled.ink, ui.color(Role::OnSurface));
} }
@@ -86,14 +86,14 @@ fn selected_and_disabled_items_use_the_right_roles() {
#[test] #[test]
fn item_states_blend_on_the_menu_container() { fn item_states_blend_on_the_menu_container() {
let ui = ui(SchemeFlavor::Light); 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, "静息不叠色(菜单底色透出来)"); assert_eq!(rest.fill, None, "静息不叠色(菜单底色透出来)");
for state in [ for state in [
Interaction::Hovered, Interaction::Hovered,
Interaction::Pressed, Interaction::Pressed,
Interaction::Focused, Interaction::Focused,
] { ] {
let fill = item_palette(&ui, state, false) let fill = item_palette(&ui, PANEL_BASE, state, false)
.fill .fill
.expect("可交互态必须给底色"); .expect("可交互态必须给底色");
assert_eq!(fill.a, 1.0, "结果必须不透明(D27)"); assert_eq!(fill.a, 1.0, "结果必须不透明(D27)");
@@ -38,7 +38,7 @@ use unlucky_design::typography::TypeRole;
use crate::icons; use crate::icons;
use crate::theme::{Ui, STROKE_PX}; 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::super::layout;
use super::geometry::{SEARCH_MAX_WIDTH, SEARCH_MIN_WIDTH, SEARCH_VIEW_MIN_HEIGHT}; use super::geometry::{SEARCH_MAX_WIDTH, SEARCH_MIN_WIDTH, SEARCH_VIEW_MIN_HEIGHT};
@@ -52,6 +52,8 @@ pub struct SearchBar<'a> {
trailing: Option<IconKind>, trailing: Option<IconKind>,
state: Option<Interaction>, state: Option<Interaction>,
width: Length, width: Length,
/// 脚下那一层的底(M234,禁用态合成用)。
base: Base,
} }
impl<'a> SearchBar<'a> { impl<'a> SearchBar<'a> {
@@ -63,6 +65,7 @@ impl<'a> SearchBar<'a> {
trailing: Some(IconKind::Filter), trailing: Some(IconKind::Filter),
state: None, state: None,
width: Length::Fill, width: Length::Fill,
base: Base::CANVAS,
} }
} }
@@ -83,12 +86,22 @@ impl<'a> SearchBar<'a> {
self.width = width; self.width = width;
self 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)。 /// 搜索条内的一行内容(bar 与 view 头部共用,保证「展开时头部不跳」——20.6)。
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn bar_content<'a, M: Clone + 'a>( fn bar_content<'a, M: Clone + 'a>(
ui: &'a Ui, ui: &'a Ui,
base: Base,
value: &'a str, value: &'a str,
placeholder: &'a str, placeholder: &'a str,
trailing: Option<IconKind>, trailing: Option<IconKind>,
@@ -102,7 +115,7 @@ fn bar_content<'a, M: Clone + 'a>(
let icon_offset = icon_edge - bar.pad_start; let icon_offset = icon_edge - bar.pad_start;
let ink = if state.is_disabled() { 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 { } else {
ui.color(Role::OnSurface) ui.color(Role::OnSurface)
}; };
@@ -190,7 +203,8 @@ pub fn search_bar<'a, M: Clone + 'a>(
let height = ui.metrics().search_bar_height; let height = ui.metrics().search_bar_height;
let background = if state.is_disabled() { let background = if state.is_disabled() {
interaction::disabled_colors(ui, Role::Surface).0 // 禁用:12% 叠在**药丸脚下的底**上(不是药丸自己的 SurfaceContainerHighest)
interaction::disabled_colors(ui, bar.base).0
} else { } else {
// 规格卡里搜索药丸与 filled 字段是**同一个底**(`SurfaceContainerHighest`): // 规格卡里搜索药丸与 filled 字段是**同一个底**(`SurfaceContainerHighest`):
// M229 r1 用的是 `High`,比 filled 字段低一档,两块并排时能看出色差(复审 F2 裁决抬档)。 // M229 r1 用的是 `High`,比 filled 字段低一档,两块并排时能看出色差(复审 F2 裁决抬档)。
@@ -212,6 +226,7 @@ pub fn search_bar<'a, M: Clone + 'a>(
container(bar_content( container(bar_content(
ui, ui,
bar.base,
bar.value, bar.value,
bar.placeholder, bar.placeholder,
bar.trailing, bar.trailing,
@@ -269,7 +284,8 @@ pub fn search_view<'a, M: Clone + 'a>(
) -> Element<'a, M> { ) -> Element<'a, M> {
// 展开面板**不跟着药丸抬档**:它是浮层(菜单语言,`SurfaceContainerHigh`), // 展开面板**不跟着药丸抬档**:它是浮层(菜单语言,`SurfaceContainerHigh`),
// 规格卡只画了收起的药丸。两块不同档是层级差,不是漏改(复审 F2 只裁药丸)。 // 规格卡只画了收起的药丸。两块不同档是层级差,不是漏改(复审 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 header_height = ui.metrics().search_bar_height;
let body_style = ui.style(TypeRole::BodyMedium); let body_style = ui.style(TypeRole::BodyMedium);
@@ -327,6 +343,7 @@ pub fn search_view<'a, M: Clone + 'a>(
let panel_content = column![ let panel_content = column![
container(bar_content( container(bar_content(
ui, ui,
Base::of(panel_role),
view.value, view.value,
view.placeholder, view.placeholder,
Some(IconKind::Close), 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::advanced::{mouse, Clipboard, Shell, Widget};
use iced::{Background, Border, Color, Element, Event, Length, Rectangle, Size}; use iced::{Background, Border, Color, Element, Event, Length, Rectangle, Size};
use unlucky_design::color::Role;
use crate::theme::Ui; use crate::theme::Ui;
use super::super::interaction::{self, Interaction, Tracker}; use super::super::interaction::{Base, Interaction, Tracker};
use super::geometry::{SliderGeometry, HANDLE_HEIGHT, HANDLE_WIDTH_ACTIVE, STOP_GAP, STOP_SIZE}; use super::geometry::{SliderGeometry, HANDLE_HEIGHT, STOP_GAP, STOP_SIZE};
mod paint;
mod track; mod track;
pub use track::TrackGeometry; pub use track::TrackGeometry;
@@ -60,6 +59,8 @@ pub struct Slider<'a, M> {
steps: Option<u32>, steps: Option<u32>,
on_change: Option<Box<dyn Fn(f32) -> M + 'a>>, on_change: Option<Box<dyn Fn(f32) -> M + 'a>>,
forced: Option<Interaction>, forced: Option<Interaction>,
/// 脚下那一层的底(M234,禁用态合成用)。
base: Base,
} }
impl<'a, M> Slider<'a, M> { impl<'a, M> Slider<'a, M> {
@@ -71,6 +72,7 @@ impl<'a, M> Slider<'a, M> {
steps: None, steps: None,
on_change: None, on_change: None,
forced: None, forced: None,
base: Base::CANVAS,
} }
} }
@@ -93,6 +95,14 @@ impl<'a, M> Slider<'a, M> {
self 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 { fn value(&self) -> f32 {
quantize(self.fraction, self.steps) quantize(self.fraction, self.steps)
@@ -240,30 +250,13 @@ where
.forced .forced
.unwrap_or_else(|| tree.state.downcast_ref::<State>().tracker.state()); .unwrap_or_else(|| tree.state.downcast_ref::<State>().tracker.state());
let disabled = state.is_disabled(); let disabled = state.is_disabled();
let colors = paint::colors(ui, self.base, state, geometry.handle);
let (active_color, inactive_color, handle_color, handle_width) = if disabled { let (active_color, inactive_color, handle_color, handle_width) = (
( colors.active,
interaction::disabled_content_over_role(ui, Role::OnSurface, false), colors.inactive,
interaction::disabled_colors(ui, Role::Surface).0, colors.handle,
interaction::disabled_content_over_role(ui, Role::OnSurface, false), colors.handle_width,
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 centre_y = bounds.y + bounds.height / 2.0; let centre_y = bounds.y + bounds.height / 2.0;
let track_x = bounds.x; 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 crate::theme::{to_iced, Ui};
use super::base::Base;
// 四个入口都收 `impl Into<Base>`:容器导出的 `BASE` 常量(推荐)与裸 `Role`(历史调用点,
// 例如 `pages/settings/java/tests/marker.rs`)都能进来。**没有隐式默认值**——不给就是编译错误,
// 这一条正是 M229 缺的:参数在、语义在,只是调用点全传了 `Role::Surface`。
/// 禁用态内容色(**按角色、按有没有容器**求值)——A1 口径的入口(**面状控件族**的底)。 /// 禁用态内容色(**按角色、按有没有容器**求值)——A1 口径的入口(**面状控件族**的底)。
/// ///
/// - `base`:控件**脚下那一层的底**([`Base`])。容器给的 `BASE` 常量,不是猜;
/// - `role`:由变体决定(Filled/Outlined/Text/Elevated = `OnSurfaceVariant`; /// - `role`:由变体决定(Filled/Outlined/Text/Elevated = `OnSurfaceVariant`;
/// FilledTonal 与三式 IconButton = `OnSurface`,见 `Variant::disabled_content`); /// FilledTonal 与三式 IconButton = `OnSurface`,见 `Variant::disabled_content`);
/// - `on_container = true`:叠在**面状控件族的 12% 容器**([`DISABLED_SURFACE_ALPHA`])上 /// - `on_container = true`:叠在**面状控件族的 12% 容器**([`DISABLED_SURFACE_ALPHA`])上
/// (Filled TextField / 有容器控件在 12% 底上求内容时); /// (Filled TextField / 有容器控件在 12% 底上求内容时),容器本身叠在 `base` 上;
/// `false`:直接叠在 **surface** 上(Text / Outlined / standard IconButton / 描边类控件的内容)。 /// `false`:直接叠在 `base` 上(Text / Outlined / standard IconButton / 描边类控件的内容)。
/// ///
/// **按钮家族不要用这个函数**(按钮容器是 10%):走 [`disabled_button_content`]。 /// **按钮家族不要用这个函数**(按钮容器是 10%):走 [`disabled_button_content`]。
/// ///
/// 之前这里写死 `OnSurfaceVariant` 且只有一个「底」,导致 FilledTonal 与 IconButton 两族偏浅 /// 之前这里写死 `OnSurfaceVariant` 且只有一个「底」,导致 FilledTonal 与 IconButton 两族偏浅
/// 15/255(视觉 r3 用像素量的)。 /// 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 之后产品方案是品牌色板 // **取 `Ui` 里已注入的方案**,不重算 baseline:M229 之后产品方案是品牌色板
// (Ink / Silver),照 baseline 重算会画出旧的天蓝中性色(复审 F1)。 // (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% 容器上)。 /// 禁用态内容色——**按钮家族**(`on_container`:是否叠在本族 10% 容器上)。
/// ///
/// 与 [`disabled_content_over_role`] 的差别只有「底」的 α:按钮 10%、面状控件 12%。 /// 与 [`disabled_content_over_role`] 的差别只有「底」的 α:按钮 10%、面状控件 12%。
/// 两个入口分开是 M86 的结论:合成一个数会让一族永远差 1/255,肉眼看不见、只有 probe 会红。 /// 两个入口分开是 M86 的结论:合成一个数会让一族永远差 1/255,肉眼看不见、只有 probe 会红。
pub fn disabled_button_content(ui: &Ui, role: Role, on_container: bool) -> Color { pub fn disabled_button_content(
disabled_content_with_alpha(ui.scheme(), role, on_container, DISABLED_CONTAINER_ALPHA) 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( fn disabled_content_with_alpha(
scheme: &Scheme, scheme: &Scheme,
base: Base,
role: Role, role: Role,
on_container: bool, on_container: bool,
container_alpha: f32, container_alpha: f32,
) -> Color { ) -> Color {
let underneath = scheme.get(base.role());
let base = if on_container { let base = if on_container {
scheme scheme
.get(Role::OnSurface) .get(Role::OnSurface)
.with_alpha(container_alpha) .with_alpha(container_alpha)
.over(scheme.get(Role::Surface)) .over(underneath)
} else { } else {
scheme.get(Role::Surface) underneath
}; };
to_iced( to_iced(
@@ -99,29 +130,32 @@ fn disabled_content_with_alpha(
/// ///
/// 顺序必须「先容器后内容」:Compose 也是这么画的,先叠容器再叠 label, /// 顺序必须「先容器后内容」:Compose 也是这么画的,先叠容器再叠 label,
/// 所以内容叠的角色是容器实色而不是底下的 surface。 /// 所以内容叠的角色是容器实色而不是底下的 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% 容器)。 /// 禁用态的一对颜色 `(容器, 内容)`——**按钮家族**(10% 容器)。
/// ///
/// - 容器 = `OnSurface` @ 10%([`DISABLED_CONTAINER_ALPHA`])叠在 `surface` 上; /// - 容器 = `OnSurface` @ 10%([`DISABLED_CONTAINER_ALPHA`])叠在 `base` 上;
/// - 内容 = **默认** `OnSurfaceVariant` @ 38%;按变体的取值走 [`disabled_button_content`]。 /// - 内容 = **默认** `OnSurfaceVariant` @ 38%;按变体的取值走 [`disabled_button_content`]。
/// ///
/// 除了按钮家族,**没有别的消费者**:面状控件(chip / slider 轨道 / switch 轨道 / 描边)走 /// 除了按钮家族,**没有别的消费者**:面状控件(chip / slider 轨道 / switch 轨道 / 描边)走
/// [`disabled_colors`] 的 12%。 /// [`disabled_colors`] 的 12%。
pub fn disabled_button_colors(ui: &Ui, surface: Role) -> (Color, Color) { pub fn disabled_button_colors(ui: &Ui, base: impl Into<Base>) -> (Color, Color) {
disabled_colors_with_alpha(ui, surface, DISABLED_CONTAINER_ALPHA) disabled_colors_with_alpha(ui, base.into(), DISABLED_CONTAINER_ALPHA)
} }
/// 两个族共用的求值:容器 = `OnSurface` @ `container_alpha` 叠在 `surface` 上,内容 = 38% 叠在容器上。 /// 两个族共用的求值:容器 = `OnSurface` @ `container_alpha` 叠在 `base` 上,内容 = 38% 叠在容器上。
fn disabled_colors_with_alpha(ui: &Ui, surface: Role, container_alpha: f32) -> (Color, Color) { fn disabled_colors_with_alpha(ui: &Ui, base: Base, container_alpha: f32) -> (Color, Color) {
let scheme = ui.scheme(); let scheme = ui.scheme();
let base = scheme.get(surface); let underneath = scheme.get(base.role());
let container = scheme let container = scheme
.get(Role::OnSurface) .get(Role::OnSurface)
.with_alpha(container_alpha) .with_alpha(container_alpha)
.over(base); .over(underneath);
// 这里是**默认口径**(OnSurfaceVariant);按变体取值请走 [`disabled_content_over_role`] // 这里是**默认口径**(OnSurfaceVariant);按变体取值请走 [`disabled_content_over_role`]
// ——FilledTonal 与三式 IconButton 的禁用内容是 `OnSurface`(A1)。 // ——FilledTonal 与三式 IconButton 的禁用内容是 `OnSurface`(A1)。
let content = scheme let content = scheme
@@ -71,7 +71,7 @@ fn surface_family_disabled_colors_are_the_12_percent_class() {
.with_alpha(EXPECTED_SURFACE_ALPHA) .with_alpha(EXPECTED_SURFACE_ALPHA)
.over(base); .over(base);
let (surface, surface_content) = disabled_colors(&ui, Role::Surface); let (surface, surface_content) = disabled_colors(&ui, Base::CANVAS);
assert_eq!( assert_eq!(
surface, surface,
to_iced(expected_container), to_iced(expected_container),
@@ -102,7 +102,7 @@ fn surface_family_disabled_colors_are_the_12_percent_class() {
"{flavor:?}:内容必须叠在容器上" "{flavor:?}:内容必须叠在容器上"
); );
// 跨族对照:面状族的合成色必须与按钮族不同(两族一旦合流,这条红) // 跨族对照:面状族的合成色必须与按钮族不同(两族一旦合流,这条红)
let (button, _) = disabled_button_colors(&ui, Role::Surface); let (button, _) = disabled_button_colors(&ui, Base::CANVAS);
assert_ne!( assert_ne!(
surface, button, surface, button,
"{flavor:?}:面状族 12% 与按钮族 10% 必须落在不同像素值上" "{flavor:?}:面状族 12% 与按钮族 10% 必须落在不同像素值上"
@@ -133,7 +133,7 @@ fn button_family_disabled_colors_are_the_10_percent_class() {
.with_alpha(EXPECTED_BUTTON_ALPHA) .with_alpha(EXPECTED_BUTTON_ALPHA)
.over(base); .over(base);
let (button, button_content) = disabled_button_colors(&ui, Role::Surface); let (button, button_content) = disabled_button_colors(&ui, Base::CANVAS);
assert_eq!( assert_eq!(
button, button,
to_iced(expected_container), 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!( assert_ne!(
button, surface, button, surface,
"{flavor:?}:10% 与 12% 必须落在不同像素值上(合成一个数就是这次要防的回归)" "{flavor:?}:10% 与 12% 必须落在不同像素值上(合成一个数就是这次要防的回归)"
@@ -158,18 +158,18 @@ fn button_family_disabled_colors_are_the_10_percent_class() {
// 内容色同理:按钮 38% 叠 10% 底 ≠ 面状 38% 叠 12% 底 // 内容色同理:按钮 38% 叠 10% 底 ≠ 面状 38% 叠 12% 底
assert_ne!( assert_ne!(
button_content, button_content,
disabled_content_over_role(&ui, Role::OnSurfaceVariant, true), disabled_content_over_role(&ui, Base::CANVAS, Role::OnSurfaceVariant, true),
"{flavor:?}:两族的「有容器」内容色不能同源" "{flavor:?}:两族的「有容器」内容色不能同源"
); );
assert_eq!( assert_eq!(
disabled_button_content(&ui, Role::OnSurfaceVariant, true), disabled_button_content(&ui, Base::CANVAS, Role::OnSurfaceVariant, true),
button_content, button_content,
"{flavor:?}:按钮内容入口必须与按钮颜色入口同源" "{flavor:?}:按钮内容入口必须与按钮颜色入口同源"
); );
// 无容器时两族完全一致(都叠 surface)——差异只在「有容器」那一支 // 无容器时两族完全一致(都叠 surface)——差异只在「有容器」那一支
assert_eq!( assert_eq!(
disabled_button_content(&ui, Role::OnSurfaceVariant, false), disabled_button_content(&ui, Base::CANVAS, Role::OnSurfaceVariant, false),
disabled_content_over_role(&ui, Role::OnSurfaceVariant, false), disabled_content_over_role(&ui, Base::CANVAS, Role::OnSurfaceVariant, false),
"{flavor:?}:没有容器时不该有族差异" "{flavor:?}:没有容器时不该有族差异"
); );
} }
@@ -192,7 +192,7 @@ fn disabled_colors_follow_the_brand_board_not_the_tone_baseline() {
let baseline = Scheme::baseline(flavor); let baseline = Scheme::baseline(flavor);
// ① 面状控件族的容器(chip 描边 / slider 未激活轨 / switch 轨道) // ① 面状控件族的容器(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( let expect_surface = to_iced(
board board
.get(Role::OnSurface) .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_ne!(button, surface, "{flavor:?}:两族 α 仍要可区分");
// ③ 内容色(两个入口) // ③ 内容色(两个入口)
assert_eq!( assert_eq!(
disabled_content_over_role(&ui, Role::OnSurfaceVariant, true), disabled_content_over_role(&ui, Base::CANVAS, Role::OnSurfaceVariant, true),
to_iced( to_iced(
board board
.get(Role::OnSurfaceVariant) .get(Role::OnSurfaceVariant)
@@ -236,7 +236,7 @@ fn disabled_colors_follow_the_brand_board_not_the_tone_baseline() {
"{flavor:?}:面状控件族内容色要按色板合成" "{flavor:?}:面状控件族内容色要按色板合成"
); );
assert_eq!( assert_eq!(
disabled_button_content(&ui, Role::OnSurfaceVariant, true), disabled_button_content(&ui, Base::CANVAS, Role::OnSurfaceVariant, true),
to_iced( to_iced(
board board
.get(Role::OnSurfaceVariant) .get(Role::OnSurfaceVariant)
@@ -267,6 +267,108 @@ 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` 常量与它自己画的色不一致(那条由 `surfaces::elevated::tests` 与
/// 像素 probe 守);③ 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 通道(像素值,报告里的证据就用它)。 /// iced 颜色 → 8bit 通道(像素值,报告里的证据就用它)。
fn channels(color: Color) -> (u8, u8, u8) { fn channels(color: Color) -> (u8, u8, u8) {
( (
@@ -39,6 +39,13 @@
//! 上表右列是**唯一**允许的取值口;左列只是给人看的,代码里一个数字都不许出现 //! 上表右列是**唯一**允许的取值口;左列只是给人看的,代码里一个数字都不许出现
//! (`components::gate` 会扫)。 //! (`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`]; //! 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)) .map(|layer| ui.state_over(layer, content, container))
} }
mod base;
mod disabled; mod disabled;
pub use base::Base;
pub use disabled::{ pub use disabled::{
disabled_button_colors, disabled_button_content, disabled_colors, disabled_content_over_role, 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 crate::theme::Ui;
use super::super::divider; use super::super::divider;
use super::super::interaction::Base;
use super::geometry::{NavLayout, NavPaneGeometry}; use super::geometry::{NavLayout, NavPaneGeometry};
mod item; mod item;
use item::{line, nav_row}; use item::{line, nav_row};
pub use item::{NavGroup, NavItem}; 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,其余全同)。 /// 导航容器(D33:`layout` 决定 88 还是 240,其余全同)。
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Navigation<'a, M> { 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 { 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::icons;
use crate::theme::Ui; 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::super::layout;
use super::super::geometry::{NavLayout, NavPaneGeometry}; use super::super::geometry::{NavLayout, NavPaneGeometry};
@@ -109,12 +109,13 @@ pub(super) struct ItemPalette {
pub(super) fn palette( pub(super) fn palette(
ui: &Ui, ui: &Ui,
base: Base,
layout: NavLayout, layout: NavLayout,
state: Interaction, state: Interaction,
selected: bool, selected: bool,
) -> ItemPalette { ) -> ItemPalette {
if state.is_disabled() { 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 { return ItemPalette {
pill: None, pill: None,
icon: ink, icon: ink,
@@ -174,6 +175,7 @@ pub(super) fn line<'a, M: 'a>(
/// 一项(rail:图标在上、标签在下;drawer:图标在左、标签在右)。 /// 一项(rail:图标在上、标签在下;drawer:图标在左、标签在右)。
pub(super) fn nav_row<'a, M: Clone + 'a>( pub(super) fn nav_row<'a, M: Clone + 'a>(
ui: &'a Ui, ui: &'a Ui,
base: Base,
item: NavItem<'a, M>, item: NavItem<'a, M>,
geometry: NavPaneGeometry, geometry: NavPaneGeometry,
) -> Element<'a, M> { ) -> Element<'a, M> {
@@ -183,7 +185,7 @@ pub(super) fn nav_row<'a, M: Clone + 'a>(
None None
}); });
let state = forced.unwrap_or(Interaction::Enabled); 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 radius = iced::border::Radius::new(geometry.pill_height / 2.0);
let icon = layout::fixed( let icon = layout::fixed(
@@ -26,7 +26,7 @@ fn ui() -> Ui {
#[test] #[test]
fn drawer_selected_uses_secondary_container() { fn drawer_selected_uses_secondary_container() {
let ui = ui(); 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.pill, Some(ui.color(Role::SecondaryContainer)));
assert_eq!(palette.icon, ui.color(Role::OnSecondaryContainer)); assert_eq!(palette.icon, ui.color(Role::OnSecondaryContainer));
assert_eq!(palette.label, ui.color(Role::OnSecondaryContainer)); assert_eq!(palette.label, ui.color(Role::OnSecondaryContainer));
@@ -36,8 +36,8 @@ fn drawer_selected_uses_secondary_container() {
#[test] #[test]
fn rail_selected_label_is_secondary() { fn rail_selected_label_is_secondary() {
let ui = ui(); let ui = ui();
let rail = palette(&ui, NavLayout::Rail, Interaction::Enabled, true); let rail = palette(&ui, BASE, NavLayout::Rail, Interaction::Enabled, true);
let drawer = palette(&ui, NavLayout::Drawer, Interaction::Enabled, true); let drawer = palette(&ui, BASE, NavLayout::Drawer, Interaction::Enabled, true);
assert_eq!(rail.label, ui.color(Role::Secondary)); assert_eq!(rail.label, ui.color(Role::Secondary));
assert_ne!(rail.label, drawer.label, "两条线的选中标签角色不同"); assert_ne!(rail.label, drawer.label, "两条线的选中标签角色不同");
// 但图标与药丸相同(同一套语言) // 但图标与药丸相同(同一套语言)
@@ -49,7 +49,7 @@ fn rail_selected_label_is_secondary() {
#[test] #[test]
fn inactive_lifts_to_on_surface() { fn inactive_lifts_to_on_surface() {
let ui = ui(); 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.label, ui.color(Role::OnSurfaceVariant));
assert_eq!(resting.pill, None); assert_eq!(resting.pill, None);
for state in [ for state in [
@@ -57,7 +57,7 @@ fn inactive_lifts_to_on_surface() {
Interaction::Focused, Interaction::Focused,
Interaction::Pressed, 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.label, ui.color(Role::OnSurface), "{state:?}");
assert_eq!(active.icon, ui.color(Role::OnSurface), "{state:?}"); assert_eq!(active.icon, ui.color(Role::OnSurface), "{state:?}");
assert!(active.pill.is_some(), "{state:?} 必须叠状态层"); assert!(active.pill.is_some(), "{state:?} 必须叠状态层");
@@ -68,10 +68,10 @@ fn inactive_lifts_to_on_surface() {
#[test] #[test]
fn disabled_draws_no_pill() { fn disabled_draws_no_pill() {
let ui = ui(); let ui = ui();
let palette = palette(&ui, NavLayout::Drawer, Interaction::Disabled, true); let palette = palette(&ui, BASE, NavLayout::Drawer, Interaction::Disabled, true);
assert_eq!( assert_eq!(
palette.icon, 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.label, palette.icon);
assert_eq!(palette.pill, None); assert_eq!(palette.pill, None);
@@ -49,7 +49,7 @@ use unlucky_design::shape::ShapeScale;
use crate::icons; use crate::icons;
use crate::theme::Ui; use crate::theme::Ui;
use super::super::interaction::{self, Interaction}; use super::super::interaction::{self, Base, Interaction};
use super::super::layout; use super::super::layout;
use super::geometry::{ use super::geometry::{
typography_headline, typography_overline, typography_supporting, ListItemGeometry, typography_headline, typography_overline, typography_supporting, ListItemGeometry,
@@ -66,6 +66,8 @@ pub struct ListItem<'a, M> {
on_press: Option<M>, on_press: Option<M>,
selected: bool, selected: bool,
state: Option<Interaction>, state: Option<Interaction>,
/// 脚下那一层的底(M234,禁用态合成用)。
base: Base,
} }
impl<'a, M> ListItem<'a, M> { impl<'a, M> ListItem<'a, M> {
@@ -80,6 +82,7 @@ impl<'a, M> ListItem<'a, M> {
on_press: Some(on_press), on_press: Some(on_press),
selected: false, selected: false,
state: None, state: None,
base: Base::CANVAS,
} }
} }
@@ -119,6 +122,12 @@ impl<'a, M> ListItem<'a, M> {
self self
} }
/// **告诉列表项它踩在什么底上**(M234):禁用态的 38% 内容叠在这个底上。
pub fn base(mut self, base: impl Into<Base>) -> Self {
self.base = base.into();
self
}
/// 转禁用:**只清消息**,槽位与选中态一律保留。 /// 转禁用:**只清消息**,槽位与选中态一律保留。
pub fn disabled(mut self) -> Self { pub fn disabled(mut self) -> Self {
self.on_press = None; self.on_press = None;
@@ -141,10 +150,10 @@ struct ItemPalette {
trailing: Color, 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() { if state.is_disabled() {
// A1:ListItemDisabled*Color = OnSurface × 0.38(无容器时叠在 surface 上) // 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 { return ItemPalette {
fill: None, fill: None,
headline: ink, headline: ink,
@@ -213,7 +222,12 @@ pub fn list_item<'a, M: Clone + 'a>(ui: &'a Ui, item: ListItem<'a, M>) -> Elemen
} else { } else {
None 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); let mut body = iced::widget::Column::new().spacing(0);
if let Some(overline) = item.overline { if let Some(overline) = item.overline {
@@ -268,9 +282,10 @@ pub fn list_item<'a, M: Clone + 'a>(ui: &'a Ui, item: ListItem<'a, M>) -> Elemen
.align_y(Alignment::Center); .align_y(Alignment::Center);
let selected = item.selected; let selected = item.selected;
let base = item.base;
let style = move |_theme: &iced::Theme, status: button::Status| { let style = move |_theme: &iced::Theme, status: button::Status| {
let state = forced.unwrap_or_else(|| Interaction::from_status(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 { button::Style {
background: colors.fill.map(Background::Color), background: colors.fill.map(Background::Color),
text_color: colors.headline, text_color: colors.headline,
@@ -56,14 +56,15 @@ fn disabled_only_clears_the_message() {
#[test] #[test]
fn disabled_content_uses_on_surface() { fn disabled_content_uses_on_surface() {
let ui = ui(); let ui = ui();
let palette = palette(&ui, Interaction::Disabled, false); let palette = palette(&ui, Base::CANVAS, Interaction::Disabled, false);
let expected = interaction::disabled_content_over_role(&ui, Role::OnSurface, false); let expected =
interaction::disabled_content_over_role(&ui, Base::CANVAS, Role::OnSurface, false);
assert_eq!(palette.headline, expected); assert_eq!(palette.headline, expected);
assert_eq!(palette.leading, expected); assert_eq!(palette.leading, expected);
assert_eq!(palette.trailing, expected); assert_eq!(palette.trailing, expected);
assert_ne!( assert_ne!(
palette.headline, palette.headline,
interaction::disabled_content_over_role(&ui, Role::OnSurfaceVariant, false), interaction::disabled_content_over_role(&ui, Base::CANVAS, Role::OnSurfaceVariant, false),
"走默认的 OnSurfaceVariant 就会偏浅一档(这条钉住 A1 的角色)" "走默认的 OnSurfaceVariant 就会偏浅一档(这条钉住 A1 的角色)"
); );
assert!(palette.fill.is_none(), "禁用不叠状态层容器"); assert!(palette.fill.is_none(), "禁用不叠状态层容器");
@@ -73,7 +74,7 @@ fn disabled_content_uses_on_surface() {
#[test] #[test]
fn selected_row_is_secondary_container() { fn selected_row_is_secondary_container() {
let ui = ui(); 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.fill, Some(ui.color(Role::SecondaryContainer)));
assert_eq!(palette.headline, ui.color(Role::OnSecondaryContainer)); assert_eq!(palette.headline, ui.color(Role::OnSecondaryContainer));
assert_eq!(palette.trailing, ui.color(Role::Primary), "4.8"); assert_eq!(palette.trailing, ui.color(Role::Primary), "4.8");
@@ -83,13 +84,13 @@ fn selected_row_is_secondary_container() {
#[test] #[test]
fn interaction_states_keep_the_content_colors() { fn interaction_states_keep_the_content_colors() {
let ui = ui(); let ui = ui();
let resting = palette(&ui, Interaction::Enabled, false); let resting = palette(&ui, Base::CANVAS, Interaction::Enabled, false);
for state in [ for state in [
Interaction::Hovered, Interaction::Hovered,
Interaction::Focused, Interaction::Focused,
Interaction::Pressed, 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.headline, resting.headline, "{state:?} 不许改文本色");
assert_eq!(active.leading, resting.leading, "{state:?} 不许改图标色"); assert_eq!(active.leading, resting.leading, "{state:?} 不许改图标色");
assert!(active.fill.is_some(), "{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 darkest = shot.darkest(&disabled).expect("禁用格里应有墨迹");
let right = rgb(interaction::disabled_content_over_role( let right = rgb(interaction::disabled_content_over_role(
&ui, &ui,
interaction::Base::CANVAS,
Role::OnSurface, Role::OnSurface,
false, false,
)); ));
let wrong = rgb(interaction::disabled_content_over_role( let wrong = rgb(interaction::disabled_content_over_role(
&ui, &ui,
interaction::Base::CANVAS,
Role::OnSurfaceVariant, Role::OnSurfaceVariant,
false, false,
)); ));
@@ -50,7 +50,7 @@ use unlucky_design::typography::TypeRole;
use crate::icons; use crate::icons;
use crate::theme::Ui; use crate::theme::Ui;
use super::super::interaction::{self, Interaction}; use super::super::interaction::{self, Base, Interaction};
use super::super::layout; use super::super::layout;
use super::geometry::{TabsGeometry, TABS_ICON_HEIGHT}; use super::geometry::{TabsGeometry, TABS_ICON_HEIGHT};
@@ -133,6 +133,8 @@ impl<'a, M> Tab<'a, M> {
pub struct Tabs<'a, M> { pub struct Tabs<'a, M> {
variant: TabsVariant, variant: TabsVariant,
tabs: Vec<Tab<'a, M>>, tabs: Vec<Tab<'a, M>>,
/// 脚下那一层的底(M234,禁用态合成用)。
base: Base,
} }
impl<'a, M> Tabs<'a, M> { impl<'a, M> Tabs<'a, M> {
@@ -141,6 +143,7 @@ impl<'a, M> Tabs<'a, M> {
Self { Self {
variant: TabsVariant::Primary, variant: TabsVariant::Primary,
tabs: Vec::new(), tabs: Vec::new(),
base: Base::CANVAS,
} }
} }
@@ -149,6 +152,7 @@ impl<'a, M> Tabs<'a, M> {
Self { Self {
variant: TabsVariant::Secondary, variant: TabsVariant::Secondary,
tabs: Vec::new(), tabs: Vec::new(),
base: Base::CANVAS,
} }
} }
@@ -157,14 +161,20 @@ impl<'a, M> Tabs<'a, M> {
self.tabs.push(tab); self.tabs.push(tab);
self self
} }
/// **告诉 tab 条它踩在什么底上**(M234):禁用态的 38% 标签/指示条叠在这个底上。
pub fn base(mut self, base: impl Into<Base>) -> Self {
self.base = base.into();
self
}
} }
/// 一个 tab 的内容色(A1 的 `Active*` / `Inactive*` / 各态表,见模块文档)。 /// 一个 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() { if state.is_disabled() {
// A1 的 Tabs 没有 Disabled* token(实读:Primary/Secondary 两套都只有 Active/Inactive × 四态), // A1 的 Tabs 没有 Disabled* token(实读:Primary/Secondary 两套都只有 Active/Inactive × 四态),
// 按 ListItem 的 38% 口径处理 —— 登记 `[估]`。 // 按 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 { if selected {
// 6.5:Primary 选中 `Primary`;Secondary 选中 `OnSurface` // 6.5:Primary 选中 `Primary`;Secondary 选中 `OnSurface`
@@ -182,13 +192,14 @@ fn content_color(ui: &Ui, primary: bool, state: Interaction, selected: bool) ->
} }
/// Primary 指示条的颜色(只有选中项有;禁用项降档,否则「点不动却亮着」)。 /// 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 { if !selected {
return None; return None;
} }
if state.is_disabled() { if state.is_disabled() {
return Some(interaction::disabled_content_over_role( return Some(interaction::disabled_content_over_role(
ui, ui,
base,
Role::OnSurface, Role::OnSurface,
false, 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> { pub fn tabs<'a, M: Clone + 'a>(ui: &'a Ui, tabs: Tabs<'a, M>) -> Element<'a, M> {
let geometry = TabsGeometry::of(ui); let geometry = TabsGeometry::of(ui);
let primary = tabs.variant.is_primary(); let primary = tabs.variant.is_primary();
let base = tabs.base;
// 6.1:带图标时容器高 **64**(`IconAndLabelTextContainerHeight`),纯文字 48 // 6.1:带图标时容器高 **64**(`IconAndLabelTextContainerHeight`),纯文字 48
let height = if tabs.tabs.iter().any(|tab| tab.icon.is_some()) { let height = if tabs.tabs.iter().any(|tab| tab.icon.is_some()) {
TABS_ICON_HEIGHT 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)) .height(Length::Fixed(height))
.align_y(Alignment::Center); .align_y(Alignment::Center);
for tab in tabs.tabs { 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 { 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 单元(内容 + 底部条)。 /// 一个 tab 单元(内容 + 底部条)。
#[allow(clippy::too_many_arguments)]
fn tab_cell<'a, M: Clone + 'a>( fn tab_cell<'a, M: Clone + 'a>(
ui: &'a Ui, ui: &'a Ui,
base: Base,
tab: Tab<'a, M>, tab: Tab<'a, M>,
geometry: TabsGeometry, geometry: TabsGeometry,
primary: bool, primary: bool,
@@ -268,7 +282,7 @@ fn tab_cell<'a, M: Clone + 'a>(
}); });
let state = forced.unwrap_or(Interaction::Enabled); let state = forced.unwrap_or(Interaction::Enabled);
let selected = tab.selected; 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 style = ui.style(TypeRole::TitleSmall);
let label = text(tab.label) let label = text(tab.label)
@@ -291,7 +305,7 @@ fn tab_cell<'a, M: Clone + 'a>(
let bar_height = geometry.bar(primary); let bar_height = geometry.bar(primary);
// 底层:内容自己决定宽度(Shrink),底部留出条的高度 —— 未选中的 Primary tab 也占同样的高 // 底层:内容自己决定宽度(Shrink),底部留出条的高度 —— 未选中的 Primary tab 也占同样的高
let base = container(content) let cell = container(content)
.height(Length::Fixed(height)) .height(Length::Fixed(height))
// Q21:要给居中就两轴都显式给(`Container` 默认 Left/Top) // Q21:要给居中就两轴都显式给(`Container` 默认 Left/Top)
.align_x(Alignment::Center) .align_x(Alignment::Center)
@@ -305,12 +319,12 @@ fn tab_cell<'a, M: Clone + 'a>(
// Primary:逐 tab 画条(要跟随该 tab 的宽度);Secondary:整行一条,在 `tabs()` 里画 // Primary:逐 tab 画条(要跟随该 tab 的宽度);Secondary:整行一条,在 `tabs()` 里画
let bar = if primary { let bar = if primary {
bar_color(ui, state, selected) bar_color(ui, base, state, selected)
} else { } else {
None None
}; };
let radius = geometry.indicator_radius(); let radius = geometry.indicator_radius();
let mut stack = Stack::new().push(base); let mut stack = Stack::new().push(cell);
if let Some(color) = bar { if let Some(color) = bar {
// 上层:Fill —— `Stack` 先按底层定尺寸,所以这里的 Fill = 这个 tab 的宽(不是整行) // 上层:Fill —— `Stack` 先按底层定尺寸,所以这里的 Fill = 这个 tab 的宽(不是整行)
stack = stack.push( 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) let button = button(stack)
.height(Length::Fixed(height)) .height(Length::Fixed(height))
.padding(0) .padding(0)
@@ -7,7 +7,7 @@ use unlucky_design::typography::Platform;
use crate::theme::Families; use crate::theme::Families;
use super::super::super::interaction; use super::super::super::interaction::{self, Base};
fn ui() -> Ui { fn ui() -> Ui {
Ui::with_platform( Ui::with_platform(
@@ -26,16 +26,16 @@ fn ui() -> Ui {
fn active_colors_follow_the_variant() { fn active_colors_follow_the_variant() {
let ui = ui(); let ui = ui();
assert_eq!( assert_eq!(
content_color(&ui, true, Interaction::Enabled, true), content_color(&ui, Base::CANVAS, true, Interaction::Enabled, true),
ui.color(Role::Primary) ui.color(Role::Primary)
); );
assert_eq!( assert_eq!(
content_color(&ui, false, Interaction::Enabled, true), content_color(&ui, Base::CANVAS, false, Interaction::Enabled, true),
ui.color(Role::OnSurface), ui.color(Role::OnSurface),
"6.5:Secondary 的选中项是 OnSurface" "6.5:Secondary 的选中项是 OnSurface"
); );
assert_ne!( assert_ne!(
content_color(&ui, false, Interaction::Enabled, true), content_color(&ui, Base::CANVAS, false, Interaction::Enabled, true),
ui.color(Role::Primary), ui.color(Role::Primary),
"把 Secondary 的选中色写成 Primary 是最常见的串线" "把 Secondary 的选中色写成 Primary 是最常见的串线"
); );
@@ -47,7 +47,7 @@ fn inactive_states_lift_to_on_surface() {
let ui = ui(); let ui = ui();
for primary in [true, false] { for primary in [true, false] {
assert_eq!( assert_eq!(
content_color(&ui, primary, Interaction::Enabled, false), content_color(&ui, Base::CANVAS, primary, Interaction::Enabled, false),
ui.color(Role::OnSurfaceVariant) ui.color(Role::OnSurfaceVariant)
); );
for state in [ for state in [
@@ -56,7 +56,7 @@ fn inactive_states_lift_to_on_surface() {
Interaction::Pressed, Interaction::Pressed,
] { ] {
assert_eq!( assert_eq!(
content_color(&ui, primary, state, false), content_color(&ui, Base::CANVAS, primary, state, false),
ui.color(Role::OnSurface), ui.color(Role::OnSurface),
"{state:?} 必须提到 OnSurface" "{state:?} 必须提到 OnSurface"
); );
@@ -68,15 +68,19 @@ fn inactive_states_lift_to_on_surface() {
#[test] #[test]
fn primary_bar_only_on_the_selected_tab() { fn primary_bar_only_on_the_selected_tab() {
let ui = ui(); let ui = ui();
assert_eq!(bar_color(&ui, Interaction::Enabled, false), None);
assert_eq!( 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)) Some(ui.color(Role::Primary))
); );
assert_eq!( assert_eq!(
bar_color(&ui, Interaction::Disabled, true), bar_color(&ui, Base::CANVAS, Interaction::Disabled, true),
Some(interaction::disabled_content_over_role( Some(interaction::disabled_content_over_role(
&ui, &ui,
Base::CANVAS,
Role::OnSurface, Role::OnSurface,
false false
)), )),
@@ -22,7 +22,7 @@ use unlucky_design::typography::TypeRole;
use crate::theme::Ui; use crate::theme::Ui;
use super::super::interaction::Interaction; use super::super::interaction::{Base, Interaction};
use super::geometry::ControlGeometry; use super::geometry::ControlGeometry;
use super::paint::{self, Frame}; use super::paint::{self, Frame};
use super::shell::{self, StateSource}; use super::shell::{self, StateSource};
@@ -58,6 +58,8 @@ pub struct Checkbox<'a, M> {
on_toggle: Option<M>, on_toggle: Option<M>,
error: bool, error: bool,
state: Option<Interaction>, state: Option<Interaction>,
/// 脚下那一层的底(M234,禁用态合成用)。
base: Base,
} }
impl<'a, M> Checkbox<'a, M> { impl<'a, M> Checkbox<'a, M> {
@@ -82,6 +84,7 @@ impl<'a, M> Checkbox<'a, M> {
on_toggle: Some(on_toggle), on_toggle: Some(on_toggle),
error: false, error: false,
state: None, state: None,
base: Base::CANVAS,
} }
} }
@@ -93,6 +96,7 @@ impl<'a, M> Checkbox<'a, M> {
on_toggle: None, on_toggle: None,
error: false, error: false,
state: None, state: None,
base: Base::CANVAS,
} }
} }
@@ -107,6 +111,12 @@ impl<'a, M> Checkbox<'a, M> {
self.state = Some(state); self.state = Some(state);
self 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 error = spec.error;
let radius = geometry.radius; let radius = geometry.radius;
let stroke = geometry.stroke; let stroke = geometry.stroke;
let base = spec.base;
let source = StateSource::new(spec.state, spec.on_toggle.is_some()); let source = StateSource::new(spec.state, spec.on_toggle.is_some());
// 勾的颜色与 hover/pressed 无关(A1:`SelectedHover/Focus/PressedIconColor` 都是 `OnPrimary`), // 勾的颜色与 hover/pressed 无关(A1:`SelectedHover/Focus/PressedIconColor` 都是 `OnPrimary`),
// 只随「选中 + 禁用」变 —— 所以这里用**静态**态求值(SVG 的颜色在构建期就要定)。 // 只随「选中 + 禁用」变 —— 所以这里用**静态**态求值(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 { let glyph: Element<'a, M> = if filled {
shell::square( shell::square(
geometry.side, 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)) .width(Length::Fixed(geometry.side))
.height(Length::Fixed(geometry.side)) .height(Length::Fixed(geometry.side))
.style(source.container_style(move |state| { .style(source.container_style(move |state| {
paint::checkbox(ui, filled, error, state, radius, stroke) paint::checkbox(ui, base, filled, error, state, radius, stroke)
.frame .frame
.container_style() .container_style()
})); }));
@@ -165,7 +176,7 @@ pub fn checkbox<'a, M: Clone + 'a>(ui: &'a Ui, spec: Checkbox<'a, M>) -> Element
text(spec.label) text(spec.label)
.size(style.size_px) .size(style.size_px)
.font(ui.font_of(style)) .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::icons;
use crate::theme::Ui; use crate::theme::Ui;
use super::super::interaction::Interaction; use super::super::interaction::{Base, Interaction};
use super::geometry::ChipGeometry; use super::geometry::ChipGeometry;
use super::paint::{self, ChipVariant}; use super::paint::{self, ChipVariant};
@@ -58,6 +58,8 @@ pub struct Chip<'a, M> {
state: Option<Interaction>, state: Option<Interaction>,
/// 「当前项」标记([`Chip::current`]):选中配色照画,但**不**降成禁用配色、也不吃五态。 /// 「当前项」标记([`Chip::current`]):选中配色照画,但**不**降成禁用配色、也不吃五态。
marker: bool, marker: bool,
/// 脚下那一层的底(M234,禁用态合成用)。
base: Base,
} }
impl<'a, M> Chip<'a, M> { impl<'a, M> Chip<'a, M> {
@@ -94,6 +96,7 @@ impl<'a, M> Chip<'a, M> {
on_press: None, on_press: None,
state: None, state: None,
marker: false, marker: false,
base: Base::CANVAS,
} }
} }
@@ -116,6 +119,7 @@ impl<'a, M> Chip<'a, M> {
on_press: None, on_press: None,
state: None, state: None,
marker: true, marker: true,
base: Base::CANVAS,
} }
} }
@@ -140,6 +144,7 @@ impl<'a, M> Chip<'a, M> {
on_press: Some(on_press), on_press: Some(on_press),
state: None, state: None,
marker: false, marker: false,
base: Base::CANVAS,
} }
} }
@@ -161,6 +166,12 @@ impl<'a, M> Chip<'a, M> {
self self
} }
/// **告诉 chip 它踩在什么底上**(M234):禁用态那 12% 容器/描边与 38% 内容叠在这个底上。
pub fn base(mut self, base: impl Into<Base>) -> Self {
self.base = base.into();
self
}
/// 静息时的外观——**唯一**把「这颗 chip 属于哪个档位」翻成颜色的地方。 /// 静息时的外观——**唯一**把「这颗 chip 属于哪个档位」翻成颜色的地方。
/// ///
/// `chip()` 用它求文字/图标色,测试也用它量对比度:档位的判据(当前项 / 选中 / 禁用) /// `chip()` 用它求文字/图标色,测试也用它量对比度:档位的判据(当前项 / 选中 / 禁用)
@@ -177,7 +188,7 @@ impl<'a, M> Chip<'a, M> {
} else { } else {
self.state.unwrap_or(Interaction::Enabled) 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 selected = spec.selected;
let marker = spec.marker; let marker = spec.marker;
let forced = spec.state; let forced = spec.state;
let base = spec.base;
// 文字/图标色只看「选中 + 禁用」(A1:Filter/Input 的 label 色在 hover/focus/pressed 不变), // 文字/图标色只看「选中 + 禁用」(A1:Filter/Input 的 label 色在 hover/focus/pressed 不变),
// 所以这里静态求值;容器(含状态层)在样式闭包里跟态走。档位的判据在 `Chip::paint` 一处 // 所以这里静态求值;容器(含状态层)在样式闭包里跟态走。档位的判据在 `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) paint::current_item(ui, geometry.radius)
} else { } else {
let state = forced.unwrap_or_else(|| Interaction::from_status(status)); 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 { button::Style {
background: paint.frame.fill.map(Background::Color), background: paint.frame.fill.map(Background::Color),
@@ -4,7 +4,7 @@ use unlucky_design::color::Role;
use crate::theme::Ui; use crate::theme::Ui;
use super::super::super::interaction::Interaction; use super::super::super::interaction::{Base, Interaction};
use super::{disabled_ink, lifted, CheckboxPaint, Frame}; use super::{disabled_ink, lifted, CheckboxPaint, Frame};
/// Checkbox 的外观。 /// Checkbox 的外观。
@@ -13,6 +13,7 @@ use super::{disabled_ink, lifted, CheckboxPaint, Frame};
/// 选中态没有描边(`SelectedOutlineWidth = 0`,走填充分支)。 /// 选中态没有描边(`SelectedOutlineWidth = 0`,走填充分支)。
pub fn checkbox( pub fn checkbox(
ui: &Ui, ui: &Ui,
base: Base,
selected: bool, selected: bool,
error: bool, error: bool,
state: Interaction, state: Interaction,
@@ -25,8 +26,8 @@ pub fn checkbox(
// A1:`SelectedDisabledContainerColor = OnSurface` + // A1:`SelectedDisabledContainerColor = OnSurface` +
// `SelectedDisabledContainerOpacity = 0.38` —— **不是** 12%(复审 P2-3)。 // `SelectedDisabledContainerOpacity = 0.38` —— **不是** 12%(复审 P2-3)。
// 与未选中禁用的描边是同一个 38% 叠色,两格看起来才成对。 // 与未选中禁用的描边是同一个 38% 叠色,两格看起来才成对。
fill: selected.then(|| disabled_ink(ui, Role::OnSurface)), fill: selected.then(|| disabled_ink(ui, base, Role::OnSurface)),
outline: (!selected).then(|| disabled_ink(ui, Role::OnSurface)), outline: (!selected).then(|| disabled_ink(ui, base, Role::OnSurface)),
stroke, stroke,
radius, radius,
}, },
@@ -4,7 +4,7 @@ use unlucky_design::color::Role;
use crate::theme::{Ui, STROKE_PX}; 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}; use super::{disabled_container, disabled_ink, state_layer, ChipPaint, Frame};
/// Chip 的三种(§13.6 / §13.7):Assist / Filter / Input。 /// Chip 的三种(§13.6 / §13.7):Assist / Filter / Input。
@@ -24,6 +24,7 @@ pub enum ChipVariant {
/// 所以未选中 + hover 的 fill 是「状态层叠在 surface 上」的实色。 /// 所以未选中 + hover 的 fill 是「状态层叠在 surface 上」的实色。
pub fn chip( pub fn chip(
ui: &Ui, ui: &Ui,
base: Base,
variant: ChipVariant, variant: ChipVariant,
selected: bool, selected: bool,
state: Interaction, state: Interaction,
@@ -39,11 +40,11 @@ pub fn chip(
}; };
if state.is_disabled() { if state.is_disabled() {
let ink = disabled_ink(ui, Role::OnSurface); let ink = disabled_ink(ui, base, Role::OnSurface);
return ChipPaint { return ChipPaint {
frame: Frame { frame: Frame {
fill: selected.then(|| disabled_container(ui)), fill: selected.then(|| disabled_container(ui, base)),
outline: (!selected).then(|| disabled_container(ui)), outline: (!selected).then(|| disabled_container(ui, base)),
stroke: STROKE_PX, stroke: STROKE_PX,
radius, radius,
}, },
@@ -102,5 +103,12 @@ pub fn chip(
/// pressed / disabled 五态(见 `Chip::current` 的文档)。对比度下限是 [`crate::components::selection::paint`] /// pressed / disabled 五态(见 `Chip::current` 的文档)。对比度下限是 [`crate::components::selection::paint`]
/// 同一条正文口径(WCAG AA 4.5:1,`unlucky_design::palette::wcag::AA_TEXT`)。 /// 同一条正文口径(WCAG AA 4.5:1,`unlucky_design::palette::wcag::AA_TEXT`)。
pub fn current_item(ui: &Ui, radius: f32) -> ChipPaint { 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 crate::theme::Ui;
use super::super::interaction::{self, Interaction}; use super::super::interaction::{self, Base, Interaction};
mod check; mod check;
mod chip; mod chip;
@@ -115,17 +115,21 @@ pub fn state_layer(ui: &Ui, state: Interaction, content: Role) -> Option<Color>
interaction::overlay_color(ui, state, content, Role::Surface) interaction::overlay_color(ui, state, content, Role::Surface)
} }
/// `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 的 /// 注意:Checkbox「选中且禁用」的**填充**不是这个 12%,A1 的
/// `SelectedDisabledContainerOpacity = 0.38` → 见 [`disabled_ink`](复审 P2-3)。 /// `SelectedDisabledContainerOpacity = 0.38` → 见 [`disabled_ink`](复审 P2-3)。
pub(super) fn disabled_container(ui: &Ui) -> Color { pub(super) fn disabled_container(ui: &Ui, base: Base) -> Color {
interaction::disabled_colors(ui, Role::Surface).0 interaction::disabled_colors(ui, base).0
} }
/// 内容禁用色:`role` @38% 叠在 surface 上(`on_container = false`)。 /// 内容禁用色:`role` @38% 叠在 `base` 上(`on_container = false`)。
pub(super) fn disabled_ink(ui: &Ui, role: Role) -> Color { pub(super) fn disabled_ink(ui: &Ui, base: Base, role: Role) -> Color {
interaction::disabled_content_over_role(ui, role, false) interaction::disabled_content_over_role(ui, base, role, false)
} }
/// hover / focus / pressed(「抬起来」的三态)。 /// hover / focus / pressed(「抬起来」的三态)。
@@ -140,9 +144,9 @@ pub(super) fn lifted(state: Interaction) -> bool {
/// ///
/// A1 里 checkbox/radio 都没有「标签色随 hover 变」的字段(标签不属于控件本体), /// 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() { if state.is_disabled() {
disabled_ink(ui, Role::OnSurface) disabled_ink(ui, base, Role::OnSurface)
} else { } else {
ui.color(Role::OnSurface) ui.color(Role::OnSurface)
} }
@@ -4,15 +4,22 @@ use unlucky_design::color::Role;
use crate::theme::Ui; use crate::theme::Ui;
use super::super::super::interaction::Interaction; use super::super::super::interaction::{Base, Interaction};
use super::{disabled_ink, lifted, Frame, RadioPaint}; use super::{disabled_ink, lifted, Frame, RadioPaint};
/// Radio 的外观。 /// Radio 的外观。
/// ///
/// **选中态 hover 不换色**:环与点在四态里都是 `Primary`(只有状态层变)。 /// **选中态 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() { let ink = if state.is_disabled() {
disabled_ink(ui, Role::OnSurface) disabled_ink(ui, base, Role::OnSurface)
} else if selected { } else if selected {
ui.color(Role::Primary) ui.color(Role::Primary)
} else if lifted(state) { } else if lifted(state) {
@@ -7,17 +7,17 @@ use unlucky_design::shape::ShapeScale;
use crate::theme::Ui; 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::super::geometry::SwitchGeometry;
use super::{disabled_container, disabled_ink, lifted, Frame, SwitchPaint}; use super::{disabled_container, disabled_ink, lifted, Frame, SwitchPaint};
/// Switch 的外观。 /// 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 geometry = SwitchGeometry::of(ui);
let radius = ShapeScale::Full.radius_px(geometry.track_height); let radius = ShapeScale::Full.radius_px(geometry.track_height);
if state.is_disabled() { if state.is_disabled() {
let container = disabled_container(ui); let container = disabled_container(ui, base);
return if selected { return if selected {
SwitchPaint { SwitchPaint {
track: Frame { track: Frame {
@@ -28,7 +28,7 @@ pub fn switch(ui: &Ui, selected: bool, state: Interaction) -> SwitchPaint {
}, },
// `DisabledSelectedHandleColor = Surface` 且不透明度 1(A1 的 fconst_1) // `DisabledSelectedHandleColor = Surface` 且不透明度 1(A1 的 fconst_1)
thumb: ui.color(Role::Surface), thumb: ui.color(Role::Surface),
icon: Some(disabled_ink(ui, Role::OnSurface)), icon: Some(disabled_ink(ui, base, Role::OnSurface)),
} }
} else { } else {
SwitchPaint { SwitchPaint {
@@ -38,7 +38,7 @@ pub fn switch(ui: &Ui, selected: bool, state: Interaction) -> SwitchPaint {
stroke: geometry.track_stroke, stroke: geometry.track_stroke,
radius, radius,
}, },
thumb: disabled_ink(ui, Role::OnSurface), thumb: disabled_ink(ui, base, Role::OnSurface),
icon: None, icon: None,
} }
}; };
@@ -25,7 +25,7 @@
use unlucky_design::color::{Role, SchemeFlavor}; use unlucky_design::color::{Role, SchemeFlavor};
use unlucky_design::typography::Platform; 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 super::pixel::{center, dims, ink_boxes, inner_ink, matches, near, rgb, shot_path, Shot};
use crate::theme::{Families, Ui}; use crate::theme::{Families, Ui};
@@ -273,7 +273,7 @@ fn selection_geometry_matches_the_reference() {
// 判据:禁用列里必须**恰好有一块** 12% 填充的 chip(= filter·on)。旧 API 把 selected // 判据:禁用列里必须**恰好有一块** 12% 填充的 chip(= filter·on)。旧 API 把 selected
// 一起删掉,这一块根本不存在(实测 0 块);能查到它、且宽度 ≥58,说明选中态与图标都还在。 // 一起删掉,这一块根本不存在(实测 0 块);能查到它、且宽度 ≥58,说明选中态与图标都还在。
// (未选中的那三个禁用 chip 只有 1px 描边,落在半像素上时覆盖率不足 50%,量不稳,故不在此断言。) // (未选中的那三个禁用 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) let disabled_chips: Vec<_> = ink_boxes(&shot, disabled_container, surface, 28, 140)
.into_iter() .into_iter()
.filter(|bbox| near(bbox.height(), 32) && bbox.width() >= 58) .filter(|bbox| near(bbox.height(), 32) && bbox.width() >= 58)
@@ -292,7 +292,8 @@ fn selection_geometry_matches_the_reference() {
); );
// ---- 复审 P2-3:**选中且禁用**的 Checkbox 容器是 38%,不是 12% ----------------- // ---- 复审 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) let ink38: Vec<_> = ink_boxes(&shot, disabled_ink, surface, 17, 24)
.into_iter() .into_iter()
.filter(|bbox| near(bbox.width(), 20) && near(bbox.height(), 20)) .filter(|bbox| near(bbox.width(), 20) && near(bbox.height(), 20))
@@ -22,7 +22,7 @@ use unlucky_design::typography::TypeRole;
use crate::theme::Ui; use crate::theme::Ui;
use super::super::interaction::Interaction; use super::super::interaction::{Base, Interaction};
use super::geometry::ControlGeometry; use super::geometry::ControlGeometry;
use super::paint::{self, Frame}; use super::paint::{self, Frame};
use super::shell::{self, StateSource}; use super::shell::{self, StateSource};
@@ -34,6 +34,8 @@ pub struct Radio<'a, M> {
selected: bool, selected: bool,
on_select: Option<M>, on_select: Option<M>,
state: Option<Interaction>, state: Option<Interaction>,
/// 脚下那一层的底(M234,禁用态合成用)。
base: Base,
} }
impl<'a, M> Radio<'a, M> { impl<'a, M> Radio<'a, M> {
@@ -44,6 +46,7 @@ impl<'a, M> Radio<'a, M> {
selected, selected,
on_select: Some(on_select), on_select: Some(on_select),
state: None, state: None,
base: Base::CANVAS,
} }
} }
@@ -54,6 +57,7 @@ impl<'a, M> Radio<'a, M> {
selected, selected,
on_select: None, on_select: None,
state: None, state: None,
base: Base::CANVAS,
} }
} }
@@ -62,6 +66,12 @@ impl<'a, M> Radio<'a, M> {
self.state = Some(state); self.state = Some(state);
self 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 radius = geometry.radius;
let stroke = geometry.stroke; let stroke = geometry.stroke;
let dot_side = geometry.inner_dot; let dot_side = geometry.inner_dot;
let base = spec.base;
let source = StateSource::new(spec.state, spec.on_select.is_some()); let source = StateSource::new(spec.state, spec.on_select.is_some());
// 内点与环的颜色与 hover/pressed 无关(A1:选中态四态都是 `Primary`), // 内点与环的颜色与 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) // Ø10 内点(半径 = 直径/2)
let dot: Element<'a, M> = match at_rest.dot { 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_x(Alignment::Center)
.align_y(Alignment::Center) .align_y(Alignment::Center)
.style(source.container_style(move |state| { .style(source.container_style(move |state| {
paint::radio(ui, selected, state, radius, stroke) paint::radio(ui, base, selected, state, radius, stroke)
.ring .ring
.container_style() .container_style()
})); }));
@@ -131,7 +142,7 @@ pub fn radio<'a, M: Clone + 'a>(ui: &'a Ui, spec: Radio<'a, M>) -> Element<'a, M
text(spec.label) text(spec.label)
.size(style.size_px) .size(style.size_px)
.font(ui.font_of(style)) .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::icons;
use crate::theme::Ui; use crate::theme::Ui;
use super::super::interaction::Interaction; use super::super::interaction::{Base, Interaction};
use super::geometry::SwitchGeometry; use super::geometry::SwitchGeometry;
use super::paint::{self, Frame}; use super::paint::{self, Frame};
use super::shell::{self, StateSource}; use super::shell::{self, StateSource};
@@ -36,6 +36,8 @@ pub struct Switch<'a, M> {
value: bool, value: bool,
on_toggle: Option<M>, on_toggle: Option<M>,
state: Option<Interaction>, state: Option<Interaction>,
/// 脚下那一层的底(M234,禁用态合成用)。
base: Base,
} }
impl<'a, M> Switch<'a, M> { impl<'a, M> Switch<'a, M> {
@@ -46,6 +48,7 @@ impl<'a, M> Switch<'a, M> {
value, value,
on_toggle: Some(on_toggle), on_toggle: Some(on_toggle),
state: None, state: None,
base: Base::CANVAS,
} }
} }
@@ -56,6 +59,7 @@ impl<'a, M> Switch<'a, M> {
value, value,
on_toggle: None, on_toggle: None,
state: None, state: None,
base: Base::CANVAS,
} }
} }
@@ -64,6 +68,12 @@ impl<'a, M> Switch<'a, M> {
self.state = Some(state); self.state = Some(state);
self 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 geometry = SwitchGeometry::of(ui);
let selected = spec.value; let selected = spec.value;
let thumb_side = geometry.thumb(selected, false); let thumb_side = geometry.thumb(selected, false);
let base = spec.base;
let source = StateSource::new(spec.state, spec.on_toggle.is_some()); let source = StateSource::new(spec.state, spec.on_toggle.is_some());
// 滑块内的勾:颜色只随选中/禁用变(A1 的 `Selected*IconColor` 都是 `OnPrimaryContainer`), // 滑块内的勾:颜色只随选中/禁用变(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 { let glyph: Element<'a, M> = match at_rest.icon {
Some(ink) => super::super::layout::centered_fixed( Some(ink) => super::super::layout::centered_fixed(
geometry.icon, 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) .align_y(Alignment::Center)
.style(source.container_style(move |state| { .style(source.container_style(move |state| {
Frame { Frame {
fill: Some(paint::switch(ui, selected, state).thumb), fill: Some(paint::switch(ui, base, selected, state).thumb),
outline: None, outline: None,
stroke: 0.0, stroke: 0.0,
radius: thumb_side / 2.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)) .width(Length::Fixed(geometry.track_width))
.height(Length::Fixed(geometry.track_height)) .height(Length::Fixed(geometry.track_height))
.style(source.container_style(move |state| { .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) text(spec.label)
.size(style.size_px) .size(style.size_px)
.font(ui.font_of(style)) .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::color::{Role, SchemeFlavor};
use unlucky_design::typography::Platform; 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::geometry::{ChipGeometry, ControlGeometry, SwitchGeometry};
use super::paint::{self, ChipVariant}; use super::paint::{self, ChipVariant};
use crate::theme::{Families, Ui}; use crate::theme::{Families, Ui};
@@ -156,7 +158,15 @@ fn chip_geometry_matches_the_tokens() {
fn checkbox_unselected_lifts_the_outline_to_on_surface() { fn checkbox_unselected_lifts_the_outline_to_on_surface() {
let ui = ui(); let ui = ui();
let g = ControlGeometry::checkbox(&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!( assert_eq!(
at_rest.frame.outline, at_rest.frame.outline,
Some(ui.color(Role::OnSurfaceVariant)) Some(ui.color(Role::OnSurfaceVariant))
@@ -166,7 +176,7 @@ fn checkbox_unselected_lifts_the_outline_to_on_surface() {
Interaction::Focused, Interaction::Focused,
Interaction::Pressed, 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!( assert_eq!(
lifted.frame.outline, lifted.frame.outline,
Some(ui.color(Role::OnSurface)), 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:描边宽度不随态变"); assert_eq!(lifted.frame.stroke, 2.0, "18.8:描边宽度不随态变");
} }
// 错误态优先于 hover // 错误态优先于 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))); 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() { fn checkbox_disabled_keeps_the_stroke_and_uses_the_a1_roles() {
let ui = ui(); let ui = ui();
let g = ControlGeometry::checkbox(&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.stroke, 2.0, "18.8:禁用描边仍 2px");
assert_eq!(unchecked.frame.fill, None, "18.8:未选中禁用内部仍透明"); assert_eq!(unchecked.frame.fill, None, "18.8:未选中禁用内部仍透明");
assert!( assert!(
@@ -195,7 +221,15 @@ fn checkbox_disabled_keeps_the_stroke_and_uses_the_a1_roles() {
"18.8:禁用描边是 OnSurface@38%,不是满档角色色" "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!( assert_eq!(
checked.glyph, checked.glyph,
ui.color(Role::Surface), ui.color(Role::Surface),
@@ -203,8 +237,8 @@ fn checkbox_disabled_keeps_the_stroke_and_uses_the_a1_roles() {
); );
// 复审 P2-3:**选中且禁用**的填充是 OnSurface@38%(`SelectedDisabledContainerOpacity`), // 复审 P2-3:**选中且禁用**的填充是 OnSurface@38%(`SelectedDisabledContainerOpacity`),
// 不是普通禁用容器的 12%。两格差一眼就能看出来,所以逐字钉住。 // 不是普通禁用容器的 12%。两格差一眼就能看出来,所以逐字钉住。
let ink38 = interaction::disabled_content_over_role(&ui, Role::OnSurface, false); let ink38 = interaction::disabled_content_over_role(&ui, Base::CANVAS, Role::OnSurface, false);
let container12 = interaction::disabled_colors(&ui, Role::Surface).0; let container12 = interaction::disabled_colors(&ui, Base::CANVAS).0;
assert_eq!( assert_eq!(
checked.frame.fill, checked.frame.fill,
Some(ink38), Some(ink38),
@@ -232,7 +266,7 @@ fn radio_keeps_primary_on_hover_when_selected() {
if state.is_disabled() { if state.is_disabled() {
continue; 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!( assert_eq!(
paint.ring.outline, primary, paint.ring.outline, primary,
"19.4:{state:?} 环仍是 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"); assert_eq!(paint.dot, primary, "19.4:{state:?} 点仍是 Primary");
} }
// 未选中:静息 OnSurfaceVariant → hover/focus/pressed 提到 OnSurface // 未选中:静息 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.dot, None, "未选中不画内点");
assert_eq!(at_rest.ring.outline, Some(ui.color(Role::OnSurfaceVariant))); 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))); assert_eq!(lifted.ring.outline, Some(ui.color(Role::OnSurface)));
} }
@@ -251,12 +299,12 @@ fn radio_keeps_primary_on_hover_when_selected() {
#[test] #[test]
fn switch_handle_roles_follow_the_tokens() { fn switch_handle_roles_follow_the_tokens() {
let ui = ui(); 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.track.fill, Some(ui.color(Role::Primary)));
assert_eq!(at_rest.thumb, ui.color(Role::OnPrimary)); assert_eq!(at_rest.thumb, ui.color(Role::OnPrimary));
assert_eq!(at_rest.icon, Some(ui.color(Role::OnPrimaryContainer))); 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!( assert_eq!(
hovered.thumb, hovered.thumb,
ui.color(Role::PrimaryContainer), ui.color(Role::PrimaryContainer),
@@ -268,7 +316,7 @@ fn switch_handle_roles_follow_the_tokens() {
"轨道不随 hover 变" "轨道不随 hover 变"
); );
let off = paint::switch(&ui, false, Interaction::Enabled); let off = paint::switch(&ui, Base::CANVAS, false, Interaction::Enabled);
assert_eq!( assert_eq!(
off.track.outline, off.track.outline,
Some(ui.color(Role::Outline)), Some(ui.color(Role::Outline)),
@@ -282,79 +330,10 @@ fn switch_handle_roles_follow_the_tokens() {
); );
assert_eq!(off.icon, None, "未选中态图标与轨道同色(不画)"); 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!( assert_eq!(
disabled.thumb, disabled.thumb,
ui.color(Role::Surface), ui.color(Role::Surface),
"A1:DisabledSelectedHandleColor=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,90 @@
//! 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, 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);
}
@@ -31,6 +31,15 @@ use unlucky_design::typography::{TypeRole, TypeStyle, Weight};
use crate::theme::Ui; 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`)。 /// ELEVATED CARD 的内边距:**20**(= `LayoutMetrics::card_padding`)。
/// ///
/// 为什么不是规格卡量到的 **24**(卡片文字 `x=88` − 框 `x=64`):本组件唯一的消费者是实例页的 /// 为什么不是规格卡量到的 **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 { .style(move |_theme| container::Style {
// 规格卡的卡底是 **surface-container**(不是 `surface-container-low`): // 规格卡的卡底是 **surface-container**(不是 `surface-container-low`):
// 层级靠与画布(`surface`)的 tone 差表达,不靠描边。 // 层级靠与画布(`surface`)的 tone 差表达,不靠描边。
background: Some(Background::Color(ui.color(Role::SurfaceContainer))), // 子控件的禁用态拿同一个角色([`BASE`])合成,两边不会漂。
background: Some(Background::Color(ui.color(BASE.role()))),
border: Border { border: Border {
radius: ui.radius_dp(shape), radius: ui.radius_dp(shape),
..Border::default() ..Border::default()
@@ -178,7 +178,8 @@ fn the_disabled_chip_still_looks_disabled() {
let disabled = Chip::<()>::filter("安装", true, ()).disabled().paint(ui); let disabled = Chip::<()>::filter("安装", true, ()).disabled().paint(ui);
let marker = Chip::<()>::current("默认").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; let container12 = interaction::disabled_colors(ui, Role::Surface).0;
assert_eq!( assert_eq!(