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