Merge branch 'feat/p4-token-clean-icon-size-and-disabled-al'
This commit is contained in:
commit
e377989327
19 files changed
+959
-184
No files matched your search
@@ -80,11 +80,13 @@ pub(super) fn assemble<'a, M: Clone + 'a>(
|
||||
|
||||
let mut row = Row::new().align_y(Alignment::Center);
|
||||
if let Some(kind) = spec.leading {
|
||||
// Q9:图标一律经固定尺寸容器(按钮图标 16、IconButton 24 都不例外)
|
||||
// Q9:图标一律经固定尺寸容器(按钮图标 18、IconButton 24 都不例外)。
|
||||
// **必须走 `view_at`**:盒 18 ≠ `RENDER_PX`(24),用 24 烘焙再缩到 18 会把视觉线宽从 1.5 压到 1.1
|
||||
// (复审 r1 P2-2 实测 1.04~1.25px)。
|
||||
row = row.push(layout::fixed(
|
||||
geometry.icon,
|
||||
geometry.icon,
|
||||
icons::view::<M>(kind, ink),
|
||||
icons::view_at::<M>(kind, ink, geometry.icon),
|
||||
));
|
||||
}
|
||||
row = row.push(label);
|
||||
@@ -92,7 +94,7 @@ pub(super) fn assemble<'a, M: Clone + 'a>(
|
||||
row = row.push(layout::fixed(
|
||||
geometry.icon,
|
||||
geometry.icon,
|
||||
icons::view::<M>(kind, ink),
|
||||
icons::view_at::<M>(kind, ink, geometry.icon),
|
||||
));
|
||||
}
|
||||
let row = row.spacing(geometry.gap);
|
||||
@@ -143,13 +145,13 @@ pub(super) fn content_color(
|
||||
disabled_content: Role,
|
||||
) -> iced::Color {
|
||||
match forced {
|
||||
// 有容器:38% 叠在**12% 容器**上(Filled / Tonal / Elevated)
|
||||
// 有容器:38% 叠在**按钮家族 10% 容器**上(Filled / Tonal / Elevated)
|
||||
Some(Interaction::Disabled) if fill.is_some() => {
|
||||
interaction::disabled_content_over_role(ui, disabled_content, true)
|
||||
interaction::disabled_button_content(ui, disabled_content, true)
|
||||
}
|
||||
// 无容器:38% 直接叠在 **surface** 上(Text / Outlined / standard IconButton)
|
||||
Some(Interaction::Disabled) => {
|
||||
interaction::disabled_content_over_role(ui, disabled_content, false)
|
||||
interaction::disabled_button_content(ui, disabled_content, false)
|
||||
}
|
||||
_ => ui.color(content),
|
||||
}
|
||||
@@ -167,7 +169,7 @@ pub(super) fn style_closure(
|
||||
.unwrap_or_else(|| Interaction::from_status(status));
|
||||
|
||||
let (background, text_color) = if state.is_disabled() {
|
||||
let (container, content) = interaction::disabled_colors(ui, Role::Surface);
|
||||
let (container, content) = interaction::disabled_button_colors(ui, Role::Surface);
|
||||
// 透明容器在禁用态依然透明(官方只改内容透明度)
|
||||
(spec.fill.map(|_| container), content)
|
||||
} else {
|
||||
@@ -184,7 +186,7 @@ pub(super) fn style_closure(
|
||||
};
|
||||
|
||||
let border_color = if state.is_disabled() {
|
||||
interaction::disabled_colors(ui, Role::Surface).0
|
||||
interaction::disabled_button_colors(ui, Role::Surface).0
|
||||
} else {
|
||||
spec.border
|
||||
.map_or_else(|| ui.color(Role::OutlineVariant), |role| ui.color(role))
|
||||
|
||||
@@ -6,20 +6,21 @@
|
||||
//! | 圆角 | `ShapeScale::Full.radius_px(高)` | 20 `[源]` 12b 1.2 |
|
||||
//! | 左右内边距(无图标) | `Space::X6` | 24 / 24 `[源]` 12b 1.3 |
|
||||
//! | 左内边距(带前置图标) | `Space::X4` | 16 `[源]` 12b 1.4 |
|
||||
//! | 图标边长 | `IconSize::Button` | 16(**官方 18 → G14 偏离**) |
|
||||
//! | 图标边长 | `IconSize::Button` | 18 `[源]` AAR `ButtonDefaults.IconSize = 18.dp`(12b 1.6 同值) |
|
||||
//! | 图标↔文字 | `Space::X2` | 8 `[源]` 12b 1.6 |
|
||||
//! | 描边 | `unlucky_design::stroke::HAIRLINE`(`crate::theme::STROKE_PX` 同一真源) | 1 `[源]` rubric G7 |
|
||||
//! | IconButton 边长 | `LayoutMetrics::control_height` | 40 `[源]` 12b 2.1 |
|
||||
//! | IconButton 图标 / 边距 | `IconSize::Toolbar` / `Space::X2` | 24 / 8 `[源]` 12b 2.2 |
|
||||
//!
|
||||
//! **没有落点的两个参考值**(登记在报告里,不在此处写数):
|
||||
//! **没有落点的一个参考值**(登记在报告里,不在此处写数):
|
||||
//!
|
||||
//! - 12b 1.7 最小宽 **58** → `G15`:iced 的 `Length` 没有 min-width 语义;按「24 + 24 内边距 +
|
||||
//! LabelLarge 二字」自然宽度 ≈ 76,实际不触发,故本 mission 不施加该约束;
|
||||
//! - 12b 1.6 图标 **18** → `G14`:令牌层 `IconSize::Button` 是 16、没有 18 档,
|
||||
//! 组件层又不许写字面量 → 先用 16 并登记 2px 偏离。
|
||||
//! LabelLarge 二字」自然宽度 ≈ 76,实际不触发,故本 mission 不施加该约束。
|
||||
//!
|
||||
//! 这两条是**能力缺口不是审美选择**,登记后由令牌层(`crates/design`)补档位。
|
||||
//! **G14 已收口(M86)**:这里曾登记「12b 1.6 图标 18 → 令牌只有 16,先用 16 并偏离 2px」。
|
||||
//! 令牌层现在有 18 档([`IconSize::Button`],出处在 `crates/design/src/icons/mod.rs`),
|
||||
//! 所以本文件取 18 就是**回到规范值**,不再是偏离。**别按 `ButtonSmallTokens.IconSize = 20` 改**:
|
||||
//! 那个 token 在 AAR 里零消费(没有任何类读它的 `getIconSize`),是死令牌。
|
||||
|
||||
use iced::Padding;
|
||||
|
||||
|
||||
@@ -9,14 +9,14 @@
|
||||
//! | 1.3 | 水平内边距(无图标) | **24 / 24** | `Space::X6` |
|
||||
//! | 1.4 | 水平内边距(带前置图标) | **16(左)/ 24(右)** | `Space::X4` / `Space::X6` |
|
||||
//! | 1.5 | 字级 | **LabelLarge 14/20 w500** | `TypeRole::LabelLarge` 强调档 |
|
||||
//! | 1.6 | 图标 18 + 图标↔文字 8 | 图标见 **G14 偏离**(令牌只有 16);间距 `Space::X2` | [`Geometry`] |
|
||||
//! | 1.6 | 图标 18 + 图标↔文字 8 | **18** = `IconSize::Button`(AAR `ButtonDefaults.IconSize`);间距 `Space::X2` | [`Geometry`] |
|
||||
//! | 1.7 | 最小宽 58 | **G15**:iced 的 `Length` 无 min-width,不施加(实测见报告) | — |
|
||||
//! | 1.8 | 状态层 + 禁用 | hover 8 / focus 10 / pressed 10;容器 12%(A1 是 10%,D8)& 内容 38%;**内容角色按变体**([`Variant::disabled_content`]) | [`super::interaction`] |
|
||||
//! | 1.8 | 状态层 + 禁用 | hover 8 / focus 10 / pressed 10;容器 **10%**(官方 `DisabledContainerOpacity`;面状控件的 12% 是另一类)& 内容 38%;**内容角色按变体**([`Variant::disabled_content`]) | [`super::interaction`] |
|
||||
//! | 1.9 | 五式色彩角色 | Filled `Primary`/`OnPrimary`;Tonal `SecondaryContainer`/`OnSecondaryContainer`;Elevated `SurfaceContainerLow`/`Primary`;Outlined 透明 + 1dp `OutlineVariant` + `OnSurfaceVariant`;Text 透明 + `OnSurfaceVariant` | [`Variant::roles`] |
|
||||
//! | 2.1 | IconButton 容器 | **40×40**、圆角 full | [`IconGeometry`] |
|
||||
//! | 2.2 | IconButton 图标 / 边距 | 图标 **24**、边距 **8** | [`IconGeometry`] |
|
||||
//! | 2.4 | Standard 各态**图标色不变** | 只有状态层变化 | [`Variant::roles`] 与态无关 |
|
||||
//! | 2.7 | IconButton 禁用 | 内容 **`OnSurface`@38%**(A1;见 [`IconVariant::disabled_content`]) | [`super::interaction::disabled_content_over_role`] |
|
||||
//! | 2.7 | IconButton 禁用 | 内容 **`OnSurface`@38%**(A1;见 [`IconVariant::disabled_content`]) | [`super::interaction::disabled_button_content`](按钮族 10% 底) |
|
||||
//! | 2.8 | 命中区 | 容器 40 + 四周各 4 = **48** | `LM.touch_target`(按钮自身不撑高) |
|
||||
//!
|
||||
//! ## 与契约 §2 的签名差异(**已登记,请 tower 确认**)
|
||||
@@ -234,7 +234,7 @@ impl<'a, M> Button<'a, M> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个禁用的按钮(无消息)。禁用不是「另一种颜色」,见 `interaction::disabled_colors`。
|
||||
/// 一个禁用的按钮(无消息)。禁用不是「另一种颜色」,见 `interaction::disabled_button_colors`。
|
||||
pub fn disabled(label: &'a str) -> Self {
|
||||
Self {
|
||||
label,
|
||||
|
||||
@@ -84,8 +84,17 @@ fn geometry_matches_the_reference_values() {
|
||||
assert_eq!(outlined.stroke, STROKE_PX, "rubric G7 全站 1px");
|
||||
assert_eq!(outlined.stroke, 1.0);
|
||||
|
||||
// 图标 16 是**令牌值**;官方 18 是 G14 登记的偏离(不在这里假装相等)
|
||||
assert_eq!(geometry.icon, 16.0, "IconSize::Button");
|
||||
// 图标 18 是**令牌值**(`IconSize::Button`,AAR `ButtonDefaults.IconSize`);
|
||||
// 12b 1.6 的 18 与它同源。**别按 `ButtonSmallTokens.IconSize = 20` 改**:那是零消费者的死令牌。
|
||||
assert_eq!(
|
||||
geometry.icon, 18.0,
|
||||
"IconSize::Button(官方 ButtonDefaults.IconSize)"
|
||||
);
|
||||
assert_eq!(
|
||||
geometry.icon,
|
||||
unlucky_design::icons::IconSize::Button.px(),
|
||||
"组件侧必须只从令牌取值(不许写字面量)"
|
||||
);
|
||||
assert_eq!(geometry.height % 4.0, 0.0, "可见高必须在 4px 栅格上(D1)");
|
||||
}
|
||||
|
||||
@@ -269,9 +278,12 @@ fn outlined_style_draws_hairline_border_and_full_radius() {
|
||||
|
||||
let disabled = style(&ui.theme(), button::Status::Disabled);
|
||||
let (container, content) =
|
||||
interaction::disabled_colors(&ui, unlucky_design::color::Role::Surface);
|
||||
interaction::disabled_button_colors(&ui, unlucky_design::color::Role::Surface);
|
||||
assert_eq!(disabled.text_color, content, "禁用内容 = 令牌 38%");
|
||||
assert_eq!(disabled.border.color, container, "禁用描边 = 令牌 12%");
|
||||
assert_eq!(
|
||||
disabled.border.color, container,
|
||||
"禁用描边 = 按钮族的令牌 10%(OutlinedButtonTokens.DisabledContainerOpacity)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -77,8 +77,8 @@ pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
heading(
|
||||
ui,
|
||||
"buttons · filled / tonal / elevated / outlined / text + icon",
|
||||
"12b §1/§2:高 40 · 圆角 full(20) · 内边距 24(带图标 16/24)· 图标 16[G14] 间距 8 · \
|
||||
hover 8% / focus 10% / pressed 10% / disabled 容器 12% 内容 38%"
|
||||
"12b §1/§2:高 40 · 圆角 full(20) · 内边距 24(带图标 16/24)· 图标 18 间距 8 · \
|
||||
hover 8% / focus 10% / pressed 10% / disabled 容器 10% 内容 38%"
|
||||
),
|
||||
rows,
|
||||
iced::widget::text(
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
//!
|
||||
//! 量什么(每条对一个已发生过的错误):实心块高 **40**(12b 1.1)· 药丸顶行**左内缩 ≈14** ⇒ 圆角 full=20
|
||||
//! (12b 1.2,8 圆角会接近通栏)· IconButton **40×40**(12b 2.1)· 字形墨迹 **16~26**
|
||||
//! (Q9:24 盒被填满会到 40)· 分隔线 **1px**(22.1)· 禁用容器 **on-surface@12%** 存在(12b 1.8)。
|
||||
//! (Q9:24 盒被填满会到 40)· 分隔线 **1px**(22.1)· 禁用容器 **on-surface@10%**(按钮族)存在(12b 1.8)。
|
||||
|
||||
use unlucky_design::color::{Role, SchemeFlavor};
|
||||
use unlucky_design::typography::Platform;
|
||||
@@ -289,12 +289,15 @@ fn gallery_geometry_matches_the_tokens() {
|
||||
// 上一版把两个 reviewer 都指出的重复块留在这里(`disabled_colors` 被调两次、
|
||||
// `disabled_container` 被绑定两次),且只有一条「全图数墨迹」的护栏 —— 深色下阈值失效、
|
||||
// 单行回退也抓不住。现在按矩阵的行逐条断言。
|
||||
let (disabled_container, _) = interaction::disabled_colors(&ui, Role::Surface);
|
||||
//
|
||||
// **按钮族**:容器 10%(官方 `*ButtonTokens.DisabledContainerOpacity = 0.1f`)、内容 38%。
|
||||
// M86 之前这里和 chip 共用同一个 12% 常量,所以断言与实现「一起错」、永远同源——拆族后才对得上。
|
||||
let (disabled_container, _) = interaction::disabled_button_colors(&ui, Role::Surface);
|
||||
assert!(
|
||||
shot.bbox_of(rgb(disabled_container))
|
||||
.iter()
|
||||
.any(|b| b.width() > 60),
|
||||
"没找到禁用态实心按钮(12b 1.8:容器 = on-surface@12%)"
|
||||
"没找到禁用态实心按钮(12b 1.8:容器 = on-surface@10%)"
|
||||
);
|
||||
|
||||
// 矩阵行序(gallery/buttons.rs 的 Variant::ALL + IconVariant::ALL),行距 48 = 40 + 8
|
||||
@@ -315,7 +318,10 @@ fn gallery_geometry_matches_the_tokens() {
|
||||
for (index, role, on_container, name) in rows {
|
||||
let y0 = label_button.top + index * row_step;
|
||||
let y1 = y0 + 44;
|
||||
let ink = interaction::disabled_content_over_role(&ui, role, on_container);
|
||||
// **与实现同源**:按钮族的内容色一律走 `disabled_button_content`(38% 叠在 10% 底上)。
|
||||
// 这里若用面状控件族的 `disabled_content_over_role`(12% 底),断言的底就比实现宽一档,
|
||||
// 差的 1/255 会让「容器对了内容错了」这类错逃过门控——M86 明确要防这个。
|
||||
let ink = interaction::disabled_button_content(&ui, role, on_container);
|
||||
let count = shot.count_in(rgb(ink), column_from, shot.width, y0, y1);
|
||||
assert!(
|
||||
count > 40,
|
||||
@@ -323,7 +329,7 @@ fn gallery_geometry_matches_the_tokens() {
|
||||
);
|
||||
}
|
||||
|
||||
// filled 禁用容器里不许出现满档 OnPrimary(旧实现是白字压在 12% 容器上 = 1.23:1)
|
||||
// filled 禁用容器里不许出现满档 OnPrimary(旧实现是白字压在容器上 = 1.23:1)
|
||||
let full_content = rgb(ui.color(Role::OnPrimary));
|
||||
for bbox in shot.bbox_of(rgb(disabled_container)) {
|
||||
if bbox.width() < 60 {
|
||||
@@ -346,7 +352,7 @@ fn gallery_geometry_matches_the_tokens() {
|
||||
顶行左内缩 {}(r=20 应 ≈14;8 圆角 ≈4;直角 0)
|
||||
实心块 {} 个(含 40×40 IconButton)
|
||||
分隔线长行 {} 行(每条 1px)
|
||||
禁用容器 (on-surface@12%) 命中 · 逐行禁用内容断言 {} 行通过
|
||||
禁用容器 (on-surface@10%,按钮族) 命中 · 逐行禁用内容断言 {} 行通过
|
||||
分隔线墨量覆盖率 {:.2}(1dp 应 ≈1.0)
|
||||
字形墨迹 {}px(24 盒应 16~26)",
|
||||
flavor,
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
//! 禁用态的颜色口径:**两个族、两套 α**(M86 收口)。
|
||||
//!
|
||||
//! ## 为什么不是「一个 12%」
|
||||
//!
|
||||
//! 官方禁用 α 分两类(A 级:`material3-android-1.4.0.aar` 的字节码 `<clinit>`):
|
||||
//!
|
||||
//! | 族 | α | 官方 token | 消费者 |
|
||||
//! |---|---|---|---|
|
||||
//! | **按钮容器** | **0.10** | `*ButtonTokens.DisabledContainerOpacity`(`Filled` / `Outlined` / `Text` / `Elevated` / `Tonal` / `Baseline`) | `buttons::assemble` |
|
||||
//! | **面状控件**容器与描边 | **0.12** | `AssistChipTokens.FlatDisabledOutlineOpacity`、`FilterChipTokens.FlatDisabled(SelectedContainer/UnselectedOutline)Opacity`、`SliderTokens.DisabledInactiveTrackOpacity`、`SwitchTokens.DisabledTrackOpacity` | chip / switch / slider / TextField 描边 |
|
||||
//! | 内容(两族共用) | 0.38 | `Disabled{Icon,LabelText,Input}Opacity` | 全部 |
|
||||
//!
|
||||
//! 历史上这里只有一个 12% 常量供两族共用:**按钮那半边一直错、chip/slider/switch 那半边一直对**。
|
||||
//! 现在按族分开两个入口——取错入口的后果是「差 1/255」,肉眼看不见,只有像素 probe 会红,
|
||||
//! 所以入口**必须按名字就能选对**。
|
||||
//!
|
||||
//! ⚠️ TextField 的 **Filled 容器**不在这两族里:官方
|
||||
//! `FilledTextFieldTokens.FieldDisabledContainerOpacity = 0.04f`,我们仍在用面状族的值
|
||||
//! (既有 D10 偏离,未在本轮收口)。`inputs::field` 走 [`disabled_colors`] 的口径,行为未变。
|
||||
//!
|
||||
//! ⚠️ **filled-tonal 是另一条已知偏离**:官方
|
||||
//! `FilledTonalButtonTokens.DisabledContainerOpacity = 0.12f`(`javap -p -constants` 可见它是个
|
||||
//! **编译期常量**,被内联进 `getDefaultFilledTonalButtonColors` 的 `ldc_w 0.12f`,所以调用点是常量、
|
||||
//! `getDisabledContainerOpacity()` 不会被调用);而本模块的 `disabled_button_*` **不区分变体**,
|
||||
//! tonal 与 filled 共用 0.10 ⇒ tonal 实际画 10%。这不是对齐、是登记过的偏离;要修得让入口知道变体,
|
||||
//! 本轮(M86)不做。
|
||||
|
||||
use iced::Color;
|
||||
|
||||
use unlucky_design::color::state::{
|
||||
DISABLED_CONTAINER_ALPHA, DISABLED_CONTENT_ALPHA, DISABLED_SURFACE_ALPHA,
|
||||
};
|
||||
use unlucky_design::color::{Role, Scheme};
|
||||
|
||||
use crate::theme::{to_iced, Ui};
|
||||
|
||||
/// 禁用态内容色(**按角色、按有没有容器**求值)——A1 口径的入口(**面状控件族**的底)。
|
||||
///
|
||||
/// - `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 / 描边类控件的内容)。
|
||||
///
|
||||
/// **按钮家族不要用这个函数**(按钮容器是 10%):走 [`disabled_button_content`]。
|
||||
///
|
||||
/// 之前这里写死 `OnSurfaceVariant` 且只有一个「底」,导致 FilledTonal 与 IconButton 两族偏浅
|
||||
/// 15/255(视觉 r3 用像素量的)。
|
||||
pub fn disabled_content_over_role(ui: &Ui, role: Role, on_container: bool) -> Color {
|
||||
let scheme = Scheme::baseline(ui.flavor());
|
||||
disabled_content_with_alpha(&scheme, 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 {
|
||||
let scheme = Scheme::baseline(ui.flavor());
|
||||
disabled_content_with_alpha(&scheme, role, on_container, DISABLED_CONTAINER_ALPHA)
|
||||
}
|
||||
|
||||
/// 两个族共用的内容色求值(「底」的 α 由调用方给)。
|
||||
fn disabled_content_with_alpha(
|
||||
scheme: &Scheme,
|
||||
role: Role,
|
||||
on_container: bool,
|
||||
container_alpha: f32,
|
||||
) -> Color {
|
||||
let base = if on_container {
|
||||
scheme
|
||||
.get(Role::OnSurface)
|
||||
.with_alpha(container_alpha)
|
||||
.over(scheme.get(Role::Surface))
|
||||
} else {
|
||||
scheme.get(Role::Surface)
|
||||
};
|
||||
|
||||
to_iced(
|
||||
scheme
|
||||
.get(role)
|
||||
.with_alpha(DISABLED_CONTENT_ALPHA)
|
||||
.over(base),
|
||||
)
|
||||
}
|
||||
|
||||
/// 禁用态的一对颜色 `(容器, 内容)`——**面状控件族**(12% 容器/描边)。
|
||||
///
|
||||
/// ⚠️ 这个名字是历史遗留(当年只有它一个),**语义是「面状控件族」**:
|
||||
/// - chip 容器与描边(`AssistChipTokens.FlatDisabledOutlineOpacity = 0.12f` 等)
|
||||
/// - switch 轨道(`SwitchTokens.DisabledTrackOpacity = 0.12f`)
|
||||
/// - slider 未激活轨(`SliderTokens.DisabledInactiveTrackOpacity = 0.12f`)
|
||||
/// - TextField 禁用描边(Filled 容器的 4% 是另一条 D10 偏离,不在这里收口)
|
||||
///
|
||||
/// **按钮家族用 [`disabled_button_colors`]**(0.10,`*ButtonTokens.DisabledContainerOpacity = 0.1f`)。
|
||||
///
|
||||
/// `ui` 不暴露 [`Scheme`],这里按同一个 flavor 求一次基线方案——调的是
|
||||
/// `unlucky_design` 的**同一个**函数与同一套角色,不是第二份令牌。
|
||||
///
|
||||
/// 顺序必须「先容器后内容」:Compose 也是这么画的,先叠容器再叠 label,
|
||||
/// 所以内容叠的角色是容器实色而不是底下的 surface。
|
||||
pub fn disabled_colors(ui: &Ui, surface: Role) -> (Color, Color) {
|
||||
disabled_colors_with_alpha(ui, surface, DISABLED_SURFACE_ALPHA)
|
||||
}
|
||||
|
||||
/// 禁用态的一对颜色 `(容器, 内容)`——**按钮家族**(10% 容器)。
|
||||
///
|
||||
/// - 容器 = `OnSurface` @ 10%([`DISABLED_CONTAINER_ALPHA`])叠在 `surface` 上;
|
||||
/// - 内容 = **默认** `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)
|
||||
}
|
||||
|
||||
/// 两个族共用的求值:容器 = `OnSurface` @ `container_alpha` 叠在 `surface` 上,内容 = 38% 叠在容器上。
|
||||
fn disabled_colors_with_alpha(ui: &Ui, surface: Role, container_alpha: f32) -> (Color, Color) {
|
||||
let scheme = Scheme::baseline(ui.flavor());
|
||||
let base = scheme.get(surface);
|
||||
let container = scheme
|
||||
.get(Role::OnSurface)
|
||||
.with_alpha(container_alpha)
|
||||
.over(base);
|
||||
// 这里是**默认口径**(OnSurfaceVariant);按变体取值请走 [`disabled_content_over_role`]
|
||||
// ——FilledTonal 与三式 IconButton 的禁用内容是 `OnSurface`(A1)。
|
||||
let content = scheme
|
||||
.get(Role::OnSurfaceVariant)
|
||||
.with_alpha(DISABLED_CONTENT_ALPHA)
|
||||
.over(container);
|
||||
|
||||
(to_iced(container), to_iced(content))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
use crate::theme::Families;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
fn ui(flavor: SchemeFlavor) -> Ui {
|
||||
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
|
||||
}
|
||||
|
||||
/// 两族的 α 必须都钉住(只钉一条,另一族被合并时会静默错 1/255)。
|
||||
#[test]
|
||||
fn both_disabled_alpha_classes_are_pinned_to_the_tokens() {
|
||||
// 绑定成局部变量再过断言:直接比两个常量会被 clippy 判成「常量断言」。
|
||||
let button_alpha = DISABLED_CONTAINER_ALPHA;
|
||||
let surface_alpha = DISABLED_SURFACE_ALPHA;
|
||||
let content_alpha = DISABLED_CONTENT_ALPHA;
|
||||
// 按钮容器:官方 *ButtonTokens.DisabledContainerOpacity = 0.1f
|
||||
assert!(
|
||||
(button_alpha - 0.10).abs() < f32::EPSILON,
|
||||
"按钮禁用容器 α 必须是 0.10(官方 DisabledContainerOpacity);\
|
||||
0.12 是面状控件那一类,别混用"
|
||||
);
|
||||
// 面状控件:chip 描边 / slider 未激活轨 / switch 轨道 = 0.12f
|
||||
assert!(
|
||||
(surface_alpha - 0.12).abs() < f32::EPSILON,
|
||||
"面状控件禁用 α 必须是 0.12(官方 FlatDisabledOutlineOpacity / \
|
||||
DisabledInactiveTrackOpacity / DisabledTrackOpacity)"
|
||||
);
|
||||
assert!((content_alpha - 0.38).abs() < f32::EPSILON);
|
||||
assert!(
|
||||
surface_alpha > button_alpha,
|
||||
"两族必须可区分:面状控件比按钮容器更实({surface_alpha} > {button_alpha})"
|
||||
);
|
||||
}
|
||||
|
||||
/// **面状控件族**的那一对颜色 = 0.12 预混合(chip / switch / slider / TextField 描边)。
|
||||
///
|
||||
/// ⚠️ 期望值**写死 0.12 / 0.38**,不从 `DISABLED_SURFACE_ALPHA` 取:
|
||||
/// 从被测常量求期望是**自指断言**——常量一改,expected 跟着改,永远相等(复审 r1 P2-1 实测:
|
||||
/// 把 `DISABLED_SURFACE_ALPHA` 改成 0.10,旧版这条照样绿)。现在常量改坏必红。
|
||||
#[test]
|
||||
fn surface_family_disabled_colors_are_the_12_percent_class() {
|
||||
const EXPECTED_SURFACE_ALPHA: f32 = 0.12;
|
||||
const EXPECTED_CONTENT_ALPHA: f32 = 0.38;
|
||||
let actual_alpha = DISABLED_SURFACE_ALPHA;
|
||||
assert_eq!(
|
||||
actual_alpha, EXPECTED_SURFACE_ALPHA,
|
||||
"面状控件族 α 必须是官方 0.12f;改常量会让下面每一条合成色断言同时红"
|
||||
);
|
||||
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let ui = ui(flavor);
|
||||
let scheme = Scheme::baseline(flavor);
|
||||
let base = scheme.get(Role::Surface);
|
||||
let expected_container = scheme
|
||||
.get(Role::OnSurface)
|
||||
.with_alpha(EXPECTED_SURFACE_ALPHA)
|
||||
.over(base);
|
||||
|
||||
let (surface, surface_content) = disabled_colors(&ui, Role::Surface);
|
||||
assert_eq!(
|
||||
surface,
|
||||
to_iced(expected_container),
|
||||
"{flavor:?}:面状控件容器 = on-surface@12% 叠表面(期望值硬编码,不取自常量)"
|
||||
);
|
||||
assert_eq!(
|
||||
surface_content,
|
||||
to_iced(
|
||||
scheme
|
||||
.get(Role::OnSurfaceVariant)
|
||||
.with_alpha(EXPECTED_CONTENT_ALPHA)
|
||||
.over(expected_container)
|
||||
),
|
||||
"{flavor:?}:面状控件内容仍是 38%,只是叠的底不同"
|
||||
);
|
||||
assert_ne!(surface, ui.color(Role::Surface), "12% 叠上去必须能看出来");
|
||||
assert_eq!(surface.a, 1.0, "禁用容器也是实色(CPU/GPU 一致)");
|
||||
assert_eq!(surface_content.a, 1.0, "禁用内容也是实色");
|
||||
// 内容叠在容器上 vs 叠在表面上,是两回事(probe 量的是前者)
|
||||
assert_ne!(
|
||||
surface_content,
|
||||
to_iced(
|
||||
scheme
|
||||
.get(Role::OnSurfaceVariant)
|
||||
.with_alpha(EXPECTED_CONTENT_ALPHA)
|
||||
.over(base)
|
||||
),
|
||||
"{flavor:?}:内容必须叠在容器上"
|
||||
);
|
||||
// 跨族对照:面状族的合成色必须与按钮族不同(两族一旦合流,这条红)
|
||||
let (button, _) = disabled_button_colors(&ui, Role::Surface);
|
||||
assert_ne!(
|
||||
surface, button,
|
||||
"{flavor:?}:面状族 12% 与按钮族 10% 必须落在不同像素值上"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **按钮家族**的那一对颜色 = 0.10 预混合,且与面状控件族**必须落在不同像素值上**。
|
||||
///
|
||||
/// 期望值同样写死(0.10 / 0.38),理由见上一条:自指断言抓不住常量被改坏。
|
||||
#[test]
|
||||
fn button_family_disabled_colors_are_the_10_percent_class() {
|
||||
const EXPECTED_BUTTON_ALPHA: f32 = 0.10;
|
||||
const EXPECTED_CONTENT_ALPHA: f32 = 0.38;
|
||||
let actual_alpha = DISABLED_CONTAINER_ALPHA;
|
||||
assert_eq!(
|
||||
actual_alpha, EXPECTED_BUTTON_ALPHA,
|
||||
"按钮族 α 必须是官方 0.1f(*ButtonTokens.DisabledContainerOpacity)"
|
||||
);
|
||||
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let ui = ui(flavor);
|
||||
let scheme = Scheme::baseline(flavor);
|
||||
let base = scheme.get(Role::Surface);
|
||||
let expected_container = scheme
|
||||
.get(Role::OnSurface)
|
||||
.with_alpha(EXPECTED_BUTTON_ALPHA)
|
||||
.over(base);
|
||||
|
||||
let (button, button_content) = disabled_button_colors(&ui, Role::Surface);
|
||||
assert_eq!(
|
||||
button,
|
||||
to_iced(expected_container),
|
||||
"{flavor:?}:按钮容器 = on-surface@10% 叠表面(官方 DisabledContainerOpacity = 0.1f)"
|
||||
);
|
||||
assert_eq!(
|
||||
button_content,
|
||||
to_iced(
|
||||
scheme
|
||||
.get(Role::OnSurfaceVariant)
|
||||
.with_alpha(EXPECTED_CONTENT_ALPHA)
|
||||
.over(expected_container)
|
||||
),
|
||||
);
|
||||
|
||||
let (surface, _) = disabled_colors(&ui, Role::Surface);
|
||||
assert_ne!(
|
||||
button, surface,
|
||||
"{flavor:?}:10% 与 12% 必须落在不同像素值上(合成一个数就是这次要防的回归)"
|
||||
);
|
||||
|
||||
// 内容色同理:按钮 38% 叠 10% 底 ≠ 面状 38% 叠 12% 底
|
||||
assert_ne!(
|
||||
button_content,
|
||||
disabled_content_over_role(&ui, Role::OnSurfaceVariant, true),
|
||||
"{flavor:?}:两族的「有容器」内容色不能同源"
|
||||
);
|
||||
assert_eq!(
|
||||
disabled_button_content(&ui, Role::OnSurfaceVariant, true),
|
||||
button_content,
|
||||
"{flavor:?}:按钮内容入口必须与按钮颜色入口同源"
|
||||
);
|
||||
// 无容器时两族完全一致(都叠 surface)——差异只在「有容器」那一支
|
||||
assert_eq!(
|
||||
disabled_button_content(&ui, Role::OnSurfaceVariant, false),
|
||||
disabled_content_over_role(&ui, Role::OnSurfaceVariant, false),
|
||||
"{flavor:?}:没有容器时不该有族差异"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,29 @@
|
||||
//! | focus | 0.10 | [`StateLayer::Focus`] |
|
||||
//! | pressed | 0.10 | [`StateLayer::Pressed`] |
|
||||
//! | dragged | 0.16 | [`StateLayer::Dragged`](按钮家族不用,列表拖拽用) |
|
||||
//! | disabled 容器 | 0.12 | [`DISABLED_CONTAINER_ALPHA`](**A1 是 0.10f**,本文件按令牌 0.12,差异登记 D8) |
|
||||
//! | disabled 容器(**按钮家族**) | 0.10 | [`DISABLED_CONTAINER_ALPHA`](按钮 `*ButtonTokens.DisabledContainerOpacity`) |
|
||||
//! | disabled 容器/描边(**面状控件族**) | 0.12 | [`DISABLED_SURFACE_ALPHA`](chip / slider 轨道 / switch 轨道 / TextField 禁用描边) |
|
||||
//! | disabled 内容 | 0.38 | [`DISABLED_CONTENT_ALPHA`] |
|
||||
//!
|
||||
//! ## ⚠️ 禁用 α 有**两类**(M86 收口,别再合成一个数)
|
||||
//!
|
||||
//! 官方禁用 α 从来不是「一个 12%」:
|
||||
//!
|
||||
//! - **按钮容器 0.10**:`BaselineButtonTokens` / `FilledButtonTokens` / `OutlinedButtonTokens` /
|
||||
//! `TextButtonTokens` / `ElevatedButtonTokens` / `TonalButtonTokens` 的 `DisabledContainerOpacity`
|
||||
//! = `0.1f`(AAR 字节码 `<clinit>` 里的 `ldc 0.1f`);
|
||||
//! - **面状控件 0.12**:`AssistChipTokens.FlatDisabledOutlineOpacity` / `FilterChipTokens.
|
||||
//! FlatDisabledSelectedContainerOpacity` / `SliderTokens.DisabledInactiveTrackOpacity` /
|
||||
//! `SwitchTokens.DisabledTrackOpacity` 都是 `0.12f`。
|
||||
//!
|
||||
//! 本模块过去只有一个 12% 常量供两条线共用:**按钮那半边一直错、chip/slider/switch 那半边一直对**。
|
||||
//! 现在按族分两个入口,谁也别再拿一个数服务两族:
|
||||
//!
|
||||
//! | 入口 | α | 谁用 |
|
||||
//! |---|---|---|
|
||||
//! | [`disabled_button_colors`] / [`disabled_button_content`] | 0.10 | **只有按钮家族**(`buttons::assemble`) |
|
||||
//! | [`disabled_colors`] / [`disabled_content_over_role`] | 0.12 | **面状控件族**:chip 容器与描边、switch 轨道、slider 未激活轨、TextField 禁用描边(`disabled_colors` 这个名字是历史遗留,**语义是面状控件族**) |
|
||||
//!
|
||||
//! 上表右列是**唯一**允许的取值口;左列只是给人看的,代码里一个数字都不许出现
|
||||
//! (`components::gate` 会扫)。
|
||||
//!
|
||||
@@ -33,10 +53,9 @@
|
||||
use iced::widget::button;
|
||||
use iced::Color;
|
||||
|
||||
use unlucky_design::color::state::{DISABLED_CONTAINER_ALPHA, DISABLED_CONTENT_ALPHA};
|
||||
use unlucky_design::color::{Role, Scheme, StateLayer};
|
||||
use unlucky_design::color::{Role, StateLayer};
|
||||
|
||||
use crate::theme::{to_iced, Ui};
|
||||
use crate::theme::Ui;
|
||||
|
||||
mod tracker;
|
||||
|
||||
@@ -63,7 +82,7 @@ pub enum Interaction {
|
||||
Focused,
|
||||
/// 按下:叠 10%。
|
||||
Pressed,
|
||||
/// 禁用:容器 12% / 内容 38%,**不叠状态层**。
|
||||
/// 禁用:按钮容器 10% / 面状控件 12% / 内容 38%,**不叠状态层**。
|
||||
Disabled,
|
||||
}
|
||||
|
||||
@@ -125,60 +144,11 @@ pub fn overlay_color(ui: &Ui, state: Interaction, content: Role, container: Role
|
||||
.map(|layer| ui.state_over(layer, content, container))
|
||||
}
|
||||
|
||||
/// 禁用态内容色(**按角色、按有没有容器**求值)——A1 口径的唯一入口。
|
||||
///
|
||||
/// - `role`:由变体决定(Filled/Outlined/Text/Elevated = `OnSurfaceVariant`;
|
||||
/// FilledTonal 与三式 IconButton = `OnSurface`,见 `Variant::disabled_content`);
|
||||
/// - `on_container = true`:叠在 **12% 容器**上(有容器的三式);
|
||||
/// `false`:直接叠在 **surface** 上(Text / Outlined / standard IconButton)。
|
||||
///
|
||||
/// 之前这里写死 `OnSurfaceVariant` 且只有一个「底」,导致 FilledTonal 与 IconButton 两族偏浅
|
||||
/// 15/255(视觉 r3 用像素量的)。
|
||||
pub fn disabled_content_over_role(ui: &Ui, role: Role, on_container: bool) -> Color {
|
||||
let scheme = Scheme::baseline(ui.flavor());
|
||||
let base = if on_container {
|
||||
scheme
|
||||
.get(Role::OnSurface)
|
||||
.with_alpha(DISABLED_CONTAINER_ALPHA)
|
||||
.over(scheme.get(Role::Surface))
|
||||
} else {
|
||||
scheme.get(Role::Surface)
|
||||
};
|
||||
mod disabled;
|
||||
|
||||
to_iced(
|
||||
scheme
|
||||
.get(role)
|
||||
.with_alpha(DISABLED_CONTENT_ALPHA)
|
||||
.over(base),
|
||||
)
|
||||
}
|
||||
|
||||
/// 禁用态的一对颜色 `(容器, 内容)`,内容按令牌口径叠在**算好的容器**之上。
|
||||
///
|
||||
/// - 容器 = `OnSurface` @ 12%([`DISABLED_CONTAINER_ALPHA`];A1 是 0.10f,见 D8)叠在 `surface` 上;
|
||||
/// - 内容 = **默认** `OnSurfaceVariant` @ 38%;按变体的取值走 [`disabled_content_over_role`]。
|
||||
///
|
||||
/// `ui` 不暴露 [`Scheme`],这里按同一个 flavor 求一次基线方案——调的是
|
||||
/// `unlucky_design` 的**同一个**函数与同一套角色,不是第二份令牌。
|
||||
///
|
||||
/// 顺序必须「先容器后内容」:Compose 也是这么画的,先叠容器再叠 label,
|
||||
/// 所以内容叠的角色是容器实色而不是底下的 surface。
|
||||
pub fn disabled_colors(ui: &Ui, surface: Role) -> (Color, Color) {
|
||||
let scheme = Scheme::baseline(ui.flavor());
|
||||
let base = scheme.get(surface);
|
||||
let container = scheme
|
||||
.get(Role::OnSurface)
|
||||
.with_alpha(DISABLED_CONTAINER_ALPHA)
|
||||
.over(base);
|
||||
// 这里是**默认口径**(OnSurfaceVariant);按变体取值请走 [`disabled_content_over_role`]
|
||||
// ——FilledTonal 与三式 IconButton 的禁用内容是 `OnSurface`(A1)。
|
||||
let content = scheme
|
||||
.get(Role::OnSurfaceVariant)
|
||||
.with_alpha(DISABLED_CONTENT_ALPHA)
|
||||
.over(container);
|
||||
|
||||
(to_iced(container), to_iced(content))
|
||||
}
|
||||
pub use disabled::{
|
||||
disabled_button_colors, disabled_button_content, disabled_colors, disabled_content_over_role,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -198,15 +168,16 @@ mod tests {
|
||||
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
|
||||
}
|
||||
|
||||
/// 契约 §3 的 α 全表:**钉住令牌值**(改令牌会让这条红,改组件抓不到)。
|
||||
/// 契约 §3 的状态层 α 全表:**钉住令牌值**(改令牌会让这条红,改组件抓不到)。
|
||||
///
|
||||
/// 禁用态的两族 α(按钮 0.10 / 面状控件 0.12)在 [`super::disabled`] 的测试里,
|
||||
/// 那边还负责「两族不能同源」的判据。
|
||||
#[test]
|
||||
fn state_layer_alphas_are_the_contract_values() {
|
||||
assert!((StateLayer::Hover.alpha() - 0.08).abs() < f32::EPSILON);
|
||||
assert!((StateLayer::Focus.alpha() - 0.10).abs() < f32::EPSILON);
|
||||
assert!((StateLayer::Pressed.alpha() - 0.10).abs() < f32::EPSILON);
|
||||
assert!((StateLayer::Dragged.alpha() - 0.16).abs() < f32::EPSILON);
|
||||
assert!((DISABLED_CONTAINER_ALPHA - 0.12).abs() < f32::EPSILON);
|
||||
assert!((DISABLED_CONTENT_ALPHA - 0.38).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -265,34 +236,4 @@ mod tests {
|
||||
assert_ne!(hover, pressed, "8% 与 10% 必须能区分");
|
||||
assert_eq!(hover.a, 1.0, "结果必须不透明(GPU/CPU 逐像素一致)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_colors_come_from_the_token_functions() {
|
||||
let ui = ui(SchemeFlavor::Light);
|
||||
let scheme = Scheme::baseline(SchemeFlavor::Light);
|
||||
|
||||
let (container, content) = disabled_colors(&ui, Role::Surface);
|
||||
let base = scheme.get(Role::Surface);
|
||||
let expected_container = scheme
|
||||
.get(Role::OnSurface)
|
||||
.with_alpha(DISABLED_CONTAINER_ALPHA)
|
||||
.over(base);
|
||||
assert_eq!(
|
||||
container,
|
||||
to_iced(expected_container),
|
||||
"容器 = on-surface@12% 叠表面"
|
||||
);
|
||||
assert_eq!(
|
||||
content,
|
||||
to_iced(
|
||||
scheme
|
||||
.get(Role::OnSurfaceVariant)
|
||||
.with_alpha(DISABLED_CONTENT_ALPHA)
|
||||
.over(expected_container)
|
||||
),
|
||||
);
|
||||
assert_ne!(container, ui.color(Role::Surface), "12% 叠上去必须能看出来");
|
||||
assert_eq!(container.a, 1.0, "禁用容器也是实色(CPU/GPU 一致)");
|
||||
assert_eq!(content.a, 1.0, "禁用内容也是实色");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Chip(`research/12b` §13):高 **32** / 圆角 **8** / 水平内边距 **8** / 元素间距 **8** /
|
||||
//! 图标 **16**(官方 18 → **G14**,与按钮家族同一登记)/ 描边 1px `OutlineVariant`。
|
||||
//! 图标 **18**(`IconSize::Button` = 官方 `AssistChipTokens.IconSize`)/ 描边 1px `OutlineVariant`。
|
||||
//!
|
||||
//! ## 三类(§13 的 Assist / Filter / Input)
|
||||
//!
|
||||
@@ -122,11 +122,12 @@ pub fn chip<'a, M: Clone + 'a>(ui: &'a Ui, spec: Chip<'a, M>) -> Element<'a, M>
|
||||
|
||||
let mut content = row![].align_y(Alignment::Center).spacing(geometry.gap);
|
||||
if let Some(kind) = spec.leading {
|
||||
// Q9:图标一律经固定尺寸容器
|
||||
// Q9:图标一律经固定尺寸容器;盒 18 ≠ RENDER_PX(24),所以必须走 `view_at` 按 18 烘焙,
|
||||
// 否则线宽会被布局缩水(复审 r1 P2-2)。
|
||||
content = content.push(super::super::layout::centered_fixed(
|
||||
geometry.icon,
|
||||
geometry.icon,
|
||||
icons::view::<M>(kind, at_rest.icon),
|
||||
icons::view_at::<M>(kind, at_rest.icon, geometry.icon),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -142,7 +143,7 @@ pub fn chip<'a, M: Clone + 'a>(ui: &'a Ui, spec: Chip<'a, M>) -> Element<'a, M>
|
||||
content = content.push(super::super::layout::centered_fixed(
|
||||
geometry.icon,
|
||||
geometry.icon,
|
||||
icons::view::<M>(kind, at_rest.icon),
|
||||
icons::view_at::<M>(kind, at_rest.icon, geometry.icon),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,11 @@
|
||||
//! `CHECKBOX/RADIO/SEARCH_BAR/NAVIGATION_DRAWER` 四组,**Switch 与 Chip 没有落点**。
|
||||
//! 本文件把它们的量收成命名常量(每个都带 A1 出处),等令牌层补
|
||||
//! `ComponentMetrics::SWITCH/CHIP` 后整段删除、改走 `Component::Switch.metrics()`。
|
||||
//! 图标 **18** 沿用按钮家族的 **G14** 偏离(令牌只有 `IconSize::Button = 16`)。
|
||||
//!
|
||||
//! **G14 已收口(M86)**:Chip 图标取 `IconSize::Button` = **18**(AAR `AssistChipTokens.IconSize =
|
||||
//! 18.0d`,与 12b 13.4 同值),不再是偏离。Switch 滑块内的图标是**另一个值 16**
|
||||
//! (`SwitchTokens.SelectedIconSize / UnselectedIconSize = 16.0d`),所以走 `IconSize::SwitchThumb`:
|
||||
//! 两者在官方就是两个 token,别为了省一个档位把它们合成一个。
|
||||
|
||||
use unlucky_design::component_map::{Component, ComponentMetrics};
|
||||
use unlucky_design::icons::IconSize;
|
||||
@@ -139,7 +143,8 @@ pub struct SwitchGeometry {
|
||||
pub thumb_unselected: f32,
|
||||
/// 按下滑块(28)。
|
||||
pub thumb_pressed: f32,
|
||||
/// 滑块内图标(16,`SwitchTokens.SelectedIconSize`;令牌 `IconSize::Button` 同值)。
|
||||
/// 滑块内图标(16 = `IconSize::SwitchThumb`;AAR `SwitchTokens.SelectedIconSize /
|
||||
/// UnselectedIconSize = 16.0d`。**不是按钮的 18**)。
|
||||
pub icon: f32,
|
||||
/// 状态层直径(40)。
|
||||
pub state_layer: f32,
|
||||
@@ -157,7 +162,7 @@ impl SwitchGeometry {
|
||||
thumb_selected: SWITCH_THUMB_SELECTED,
|
||||
thumb_unselected: SWITCH_THUMB_UNSELECTED,
|
||||
thumb_pressed: SWITCH_THUMB_PRESSED,
|
||||
icon: IconSize::Button.px(),
|
||||
icon: IconSize::SwitchThumb.px(),
|
||||
state_layer: SWITCH_STATE_LAYER,
|
||||
hit: ui.metrics().touch_target,
|
||||
}
|
||||
@@ -227,7 +232,7 @@ pub struct ChipGeometry {
|
||||
pub pad: f32,
|
||||
/// 元素间距(8,同上)。
|
||||
pub gap: f32,
|
||||
/// 图标边长(16;官方 18 → **G14** 偏离,与按钮家族同一登记)。
|
||||
/// 图标边长(18 = `IconSize::Button`;AAR `AssistChipTokens.IconSize = 18.0d`,12b 13.4 同值)。
|
||||
pub icon: f32,
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,10 @@
|
||||
//! - 全部组件**对消息类型泛型**(`M: Clone`),不 import `crate::app::Message`;
|
||||
//! - hover / pressed / focus / disabled 的 α 只从 [`super::interaction`] 取(本目录零 α 字面量);
|
||||
//! - 精确尺寸只从 [`super::layout`](`centered_fixed` / `fixed`)与 `geometry` 取,不许手写;
|
||||
//! - 图标一律 `icons::view` + 固定尺寸容器(Q9),圆角按 D32 分层(微控件 2 / 容器刻度 / full)。
|
||||
//! - 图标一律 `icons::view_at(kind, color, geometry.icon)` + 固定尺寸容器(Q9):本目录的盒是
|
||||
//! **18(chip)与 16(switch 滑块)**,都不是 `icons::RENDER_PX`(24),所以必须按盒烘焙——用
|
||||
//! `icons::view`(24 烘焙)会被布局缩到 1.1px 视觉线宽(复审 r1 P2-2)。圆角按 D32 分层
|
||||
//! (微控件 2 / 容器刻度 / full)。
|
||||
|
||||
mod checkbox;
|
||||
mod chip;
|
||||
|
||||
@@ -265,7 +265,7 @@ fn selection_geometry_matches_the_reference() {
|
||||
);
|
||||
assert!(
|
||||
chips[0].width() >= 58,
|
||||
"13.1:chip 宽应 ≥58(图标 16 + 两侧各 8 + 文字),实测 {}",
|
||||
"13.1:chip 宽应 ≥58(图标 18 + 两侧各 8 + 文字),实测 {}",
|
||||
chips[0].width()
|
||||
);
|
||||
|
||||
|
||||
@@ -80,7 +80,8 @@ pub fn switch<'a, M: Clone + 'a>(ui: &'a Ui, spec: Switch<'a, M>) -> Element<'a,
|
||||
Some(ink) => super::super::layout::centered_fixed(
|
||||
geometry.icon,
|
||||
geometry.icon,
|
||||
icons::view::<M>(IconKind::Check, ink),
|
||||
// 盒 16 ≠ RENDER_PX(24):必须按 16 烘焙,否则线宽被布局压细(复审 r1 P2-2)。
|
||||
icons::view_at::<M>(IconKind::Check, ink, geometry.icon),
|
||||
),
|
||||
None => Spacer::new().into(),
|
||||
};
|
||||
|
||||
@@ -95,7 +95,15 @@ fn switch_geometry_matches_the_tokens() {
|
||||
assert_eq!(g.thumb(true, false), 24.0, "10.2:选中滑块 24");
|
||||
assert_eq!(g.thumb(false, true), 28.0, "10.2:按下滑块 28");
|
||||
assert_eq!(g.thumb(true, true), 28.0, "10.2:按下档与选中无关");
|
||||
assert_eq!(g.icon, 16.0, "10.5:滑块内图标 16");
|
||||
assert_eq!(
|
||||
g.icon, 16.0,
|
||||
"10.5:滑块内图标 16(官方 SwitchTokens.SelectedIconSize,令牌 IconSize::SwitchThumb)"
|
||||
);
|
||||
assert_eq!(
|
||||
g.icon,
|
||||
unlucky_design::icons::IconSize::SwitchThumb.px(),
|
||||
"滑块图标必须走 SwitchThumb 档,不许借按钮的 18"
|
||||
);
|
||||
assert_eq!(g.state_layer, 40.0, "10.6:状态层 Ø40");
|
||||
assert_eq!(g.hit, 48.0, "命中区 48");
|
||||
|
||||
@@ -118,7 +126,7 @@ fn switch_geometry_matches_the_tokens() {
|
||||
}
|
||||
}
|
||||
|
||||
/// §13.1 / §13.3 / §13.4:Chip 高 32、圆角 8、内边距 8、图标 16(官方 18 → G14)。
|
||||
/// §13.1 / §13.3 / §13.4:Chip 高 32、圆角 8、内边距 8、图标 18(官方 `AssistChipTokens.IconSize`,G14 已收口)。
|
||||
#[test]
|
||||
fn chip_geometry_matches_the_tokens() {
|
||||
let ui = ui();
|
||||
@@ -127,7 +135,18 @@ fn chip_geometry_matches_the_tokens() {
|
||||
assert_eq!(g.radius, 8.0, "13.1:圆角 8(ContainerShape=CornerSmall)");
|
||||
assert_eq!(g.pad, 8.0, "13.3:水平内边距 8");
|
||||
assert_eq!(g.gap, 8.0, "13.3:元素间距 8");
|
||||
assert_eq!(g.icon, 16.0, "13.4:图标 16(官方 18 → G14 偏离)");
|
||||
// 旧期望 16 记的是 G14 偏离(令牌层当时没有 18 档);令牌补齐后官方值 18 才是对的
|
||||
// ——AAR `AssistChipTokens.IconSize = 18.0d`,12b 13.4 也写 18。这不是「把期望改成实现」,
|
||||
// 是偏离被取消。
|
||||
assert_eq!(
|
||||
g.icon, 18.0,
|
||||
"13.4:图标 18(官方 AssistChipTokens.IconSize)"
|
||||
);
|
||||
assert_eq!(
|
||||
g.icon,
|
||||
unlucky_design::icons::IconSize::Button.px(),
|
||||
"chip 图标与按钮图标同源(令牌 IconSize::Button)"
|
||||
);
|
||||
}
|
||||
|
||||
/// §18.2 / A1:未选中 Checkbox 在 hover/focus/pressed 下描边提到 `OnSurface`(不是 `OnSurfaceVariant`)。
|
||||
|
||||
+300
-29
@@ -10,29 +10,60 @@
|
||||
//!
|
||||
//! 令牌的 `Icon::stroke_width(render_px)` 已经把「视觉 1.5px」折算成 **viewBox 单位**
|
||||
//! (`1.5 × 24 / render_px`),本模块**不重算也不手抄常数**。
|
||||
//! 本骨架所有图标都按 24px 渲染(12b 2.2 标题栏 / 5.5 导航),所以每个图标只有一份位图缓存。
|
||||
//!
|
||||
//! ## ⚠️ 渲染尺寸**不是**一个全局值(M86 收口)
|
||||
//!
|
||||
//! **盒子多宽就按多宽烘焙**,规则只有两条:
|
||||
//!
|
||||
//! | 盒尺寸 | 用哪个入口 |
|
||||
//! |---|---|
|
||||
//! | **等于** [`RENDER_PX`](24) | [`view`](导航、卡片头、表单、IconButton…) |
|
||||
//! | **不等于** 24(按钮 18 / chip 18 / switch 滑块 16 / 标题栏 caption 20) | **[`view_at`](必须)** |
|
||||
//!
|
||||
//! 尺寸只有一个来源:`unlucky_design::icons` 的令牌([`IconSize`] /
|
||||
//! [`unlucky_design::icons::TITLEBAR_CAPTION_ICON_PX`])——本文件里不许出现 `18.0` / `20.0` / `24.0`
|
||||
//! 这类字面量,也不许把某个组件的尺寸「凑」进 [`RENDER_PX`]。
|
||||
//!
|
||||
//! **为什么必须按盒烘焙**:`stroke-width` 是 viewBox 单位,同一个 viewBox 线宽在更小的盒子里
|
||||
//! 视觉更细。把 24px 烘焙的位图塞进 18px 盒子,视觉线宽会从 1.5px 掉到 **1.125px**
|
||||
//! (复审 r1 实测 1.04~1.25px,而同屏 caption 的 20px 走 [`view_at`] 实测正好 1.50px)。
|
||||
//! 所以:**每个用到的尺寸各缓存一份**,缓存键是 `(图标种类, 渲染尺寸)`——但「缓存支持多尺寸」
|
||||
//! 不等于「调用点都用上了」,**不等于 24 的盒子一律走 [`view_at`]** 才是这条的落点。
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use iced::widget::svg;
|
||||
use iced::Color;
|
||||
|
||||
use unlucky_design::icons::{IconKind, IconPaint, IconSize};
|
||||
|
||||
/// 骨架期图标的统一渲染尺寸(12b:标题栏与导航轨都是 24)。
|
||||
/// 图标缓存键里的尺寸精度(px × 100 取整),避免拿 f32 做哈希键。
|
||||
const PX_KEY_SCALE: f32 = 100.0;
|
||||
|
||||
/// 骨架期图标的默认渲染尺寸(12b:标题栏与导航轨都是 24;`SmallIconButtonTokens.IconSize = 24`)。
|
||||
pub const RENDER_PX: f32 = IconSize::Toolbar.px();
|
||||
|
||||
/// 标题栏 caption 三键(最小化 / 最大化 / 关闭)的图标边长。
|
||||
///
|
||||
/// **项目偏离**(用户点名):官方 `SmallIconButtonTokens.IconSize = 24`,这里 20;容器 46×48 不动。
|
||||
/// 值只在令牌层定义一次(`unlucky_design::icons::TITLEBAR_CAPTION_ICON_PX`),本文件只是转口。
|
||||
pub const CAPTION_PX: f32 = unlucky_design::icons::TITLEBAR_CAPTION_ICON_PX;
|
||||
|
||||
/// 生成一个图标的 SVG 源码(`viewBox` 24×24,颜色用 `currentColor` 交给 iced 的色滤镜)。
|
||||
///
|
||||
/// 之所以用 `currentColor` 而不是把令牌色写进 SVG:色滤镜(`svg::Style::color`)会按
|
||||
/// **alpha 蒙版**重涂 RGB,这样一份 SVG 位图能被浅/深两套主题复用,缓存不会翻倍。
|
||||
pub fn source(kind: IconKind) -> String {
|
||||
///
|
||||
/// `render_px` 决定两件事:SVG 的 `width`/`height` 属性与 `stroke-width` 的 viewBox 折算,
|
||||
/// 两者必须同源,否则线宽会被二次缩放(见模块文档最后一段)。
|
||||
pub fn source(kind: IconKind, render_px: f32) -> String {
|
||||
let icon = kind.icon();
|
||||
let paint = match icon.paint {
|
||||
IconPaint::Stroke => format!(
|
||||
"fill=\"none\" stroke=\"currentColor\" stroke-width=\"{}\" \
|
||||
stroke-linecap=\"round\" stroke-linejoin=\"round\"",
|
||||
icon.stroke_width(RENDER_PX)
|
||||
icon.stroke_width(render_px)
|
||||
),
|
||||
IconPaint::Fill => "fill=\"currentColor\" stroke=\"none\"".to_owned(),
|
||||
};
|
||||
@@ -40,20 +71,13 @@ pub fn source(kind: IconKind) -> String {
|
||||
format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{0}\" height=\"{0}\" \
|
||||
viewBox=\"0 0 24 24\"><path d=\"{1}\" {paint}/></svg>",
|
||||
RENDER_PX, icon.path
|
||||
render_px, icon.path
|
||||
)
|
||||
}
|
||||
|
||||
/// 图标的 SVG 句柄(按 `IconKind` 缓存;句柄 id 是内容哈希,所以 iced 侧也能命中缓存)。
|
||||
fn handle(kind: IconKind) -> svg::Handle {
|
||||
static HANDLES: OnceLock<Vec<svg::Handle>> = OnceLock::new();
|
||||
|
||||
let handles = HANDLES.get_or_init(|| {
|
||||
IconKind::ALL
|
||||
.iter()
|
||||
.map(|kind| svg::Handle::from_memory(source(*kind).into_bytes()))
|
||||
.collect()
|
||||
});
|
||||
/// 图标的 SVG 句柄(按 `(种类, 渲染尺寸)` 缓存;句柄 id 是内容哈希,所以 iced 侧也能命中缓存)。
|
||||
fn handle(kind: IconKind, render_px: f32) -> svg::Handle {
|
||||
static HANDLES: OnceLock<Mutex<HashMap<(IconKind, u32), svg::Handle>>> = OnceLock::new();
|
||||
|
||||
// 依赖「枚举下标 == IconKind::ALL 下标」(令牌层用 `GLYPHS[self as usize]` 同一约定)。
|
||||
// 这条断言把约定钉在测试与 debug 构建里,`IconKind` 一旦乱序插入就会立刻炸。
|
||||
@@ -63,14 +87,34 @@ fn handle(kind: IconKind) -> svg::Handle {
|
||||
"IconKind::ALL 必须与枚举声明顺序一致"
|
||||
);
|
||||
|
||||
handles[kind as usize].clone()
|
||||
let key = (kind, (render_px * PX_KEY_SCALE).round() as u32);
|
||||
let cache = HANDLES.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
let mut cache = cache
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
cache
|
||||
.entry(key)
|
||||
.or_insert_with(|| svg::Handle::from_memory(source(kind, render_px).into_bytes()))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// 一个图标控件(尺寸走令牌,颜色走令牌角色)。
|
||||
/// 一个图标控件:按**令牌默认尺寸**([`RENDER_PX`])渲染,颜色走令牌角色。
|
||||
pub fn view<'a, Message: 'a>(kind: IconKind, color: Color) -> iced::Element<'a, Message> {
|
||||
svg(handle(kind))
|
||||
.width(RENDER_PX)
|
||||
.height(RENDER_PX)
|
||||
view_at(kind, color, RENDER_PX)
|
||||
}
|
||||
|
||||
/// 一个图标控件:按调用方给的**令牌尺寸**渲染(`render_px` 必须来自 `unlucky_design::icons`)。
|
||||
///
|
||||
/// 只有需要「组件自己的图标尺寸不同」时才用这个入口(目前只有标题栏 caption 三键)。
|
||||
/// 别为了摆一个位置而拿它调字面量:位置交给布局(`layout::fixed` / `centered_fixed`)。
|
||||
pub fn view_at<'a, Message: 'a>(
|
||||
kind: IconKind,
|
||||
color: Color,
|
||||
render_px: f32,
|
||||
) -> iced::Element<'a, Message> {
|
||||
svg(handle(kind, render_px))
|
||||
.width(render_px)
|
||||
.height(render_px)
|
||||
.style(move |_theme, _status| svg::Style { color: Some(color) })
|
||||
.into()
|
||||
}
|
||||
@@ -82,7 +126,7 @@ mod tests {
|
||||
#[test]
|
||||
fn every_icon_produces_wellformed_svg() {
|
||||
for kind in IconKind::ALL {
|
||||
let source = source(kind);
|
||||
let source = source(kind, RENDER_PX);
|
||||
assert!(source.starts_with("<svg"), "{} 的 SVG 头不对", kind.name());
|
||||
assert!(source.ends_with("</svg>"), "{} 的 SVG 尾不对", kind.name());
|
||||
assert!(source.contains("viewBox=\"0 0 24 24\""), "{}", kind.name());
|
||||
@@ -97,7 +141,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn stroke_icons_carry_the_token_stroke_width() {
|
||||
let stroke = source(IconKind::Settings);
|
||||
let stroke = source(IconKind::Settings, RENDER_PX);
|
||||
let expected = IconKind::Settings.icon().stroke_width(RENDER_PX);
|
||||
assert!(
|
||||
stroke.contains(&format!("stroke-width=\"{expected}\"")),
|
||||
@@ -108,19 +152,246 @@ mod tests {
|
||||
assert!((expected - 1.5).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
/// 默认尺寸**只从令牌取**:谁把 `RENDER_PX` 改成字面量或改成别的档,这条会红。
|
||||
#[test]
|
||||
fn default_render_size_comes_from_the_token() {
|
||||
assert_eq!(
|
||||
RENDER_PX,
|
||||
IconSize::Toolbar.px(),
|
||||
"默认 24 来自 IconSize::Toolbar"
|
||||
);
|
||||
assert_eq!(RENDER_PX, 24.0);
|
||||
assert!(
|
||||
source(IconKind::Close, RENDER_PX).contains("width=\"24\""),
|
||||
"默认渲染尺寸必须是 24"
|
||||
);
|
||||
}
|
||||
|
||||
/// `view_at` 必须**按请求尺寸烘焙**(复审 r1 P1-2/P2-2 的守门断言)。
|
||||
///
|
||||
/// 两条路径的差别就是这条测试的全部意义:
|
||||
/// - **烘焙路径**:`source(kind, 盒)` → SVG `width=盒` + 线宽 `1.5×24/盒` ⇒ 视觉恒 **1.5px**;
|
||||
/// - **缩放路径**(错的那条):24 烘焙(`stroke-width="1.5"`)塞进 18 盒 ⇒ 视觉 **1.125px**。
|
||||
#[test]
|
||||
fn view_at_bakes_the_requested_size() {
|
||||
// (盒, 该盒应有的 viewBox 线宽, 盒子来源)——caption 20 / 按钮 18 / switch 滑块 16。
|
||||
let cases = [
|
||||
(CAPTION_PX, 1.8, "caption"),
|
||||
(IconSize::Button.px(), 2.0, "按钮"),
|
||||
(IconSize::SwitchThumb.px(), 2.25, "switch 滑块"),
|
||||
];
|
||||
for (px, stroke, what) in cases {
|
||||
let svg = source(IconKind::Close, px);
|
||||
// `stroke-width` 由 `format!("{}", f32)` 渲染:2.0 会打印成 `2`,所以先比数值再比字符串。
|
||||
let baked = unlucky_design::icons::stroke_width_for(px, 1.5);
|
||||
assert!(
|
||||
(baked - stroke).abs() < 1.0e-6,
|
||||
"{what}:{px}px 盒的 viewBox 线宽应为 {stroke},实际 {baked}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains(&format!("width=\"{px}\""))
|
||||
&& svg.contains(&format!("stroke-width=\"{baked}\"")),
|
||||
"{what}:svg 必须按 {px} 烘焙:{svg}"
|
||||
);
|
||||
let visual = unlucky_design::icons::visual_stroke_px(px, baked);
|
||||
assert!(
|
||||
(visual - 1.5).abs() < 1.0e-6,
|
||||
"{what}:烘焙路径视觉线宽应为 1.5,实际 {visual}"
|
||||
);
|
||||
}
|
||||
|
||||
// 反证:拿 24 烘焙的那份去填 18 盒(= 用 `view` 装进小盒),视觉只剩 1.125px。
|
||||
let scaled = source(IconKind::Check, RENDER_PX);
|
||||
assert!(scaled.contains("width=\"24\""));
|
||||
let scaled_visual = unlucky_design::icons::visual_stroke_px(IconSize::Button.px(), 1.5);
|
||||
assert!(
|
||||
(scaled_visual - 1.125).abs() < 1.0e-6,
|
||||
"缩放路径只该得到 1.125px(这就是为什么盒 ≠ 24 必须走 view_at)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filled_icons_do_not_get_a_stroke() {
|
||||
// `more` 是三个填充圆点
|
||||
let filled = source(IconKind::More);
|
||||
let filled = source(IconKind::More, RENDER_PX);
|
||||
assert!(filled.contains("fill=\"currentColor\""));
|
||||
assert!(filled.contains("stroke=\"none\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_are_cached_per_kind() {
|
||||
let first = handle(IconKind::Close);
|
||||
let second = handle(IconKind::Close);
|
||||
assert_eq!(first.id(), second.id());
|
||||
assert_ne!(first.id(), handle(IconKind::Minimize).id());
|
||||
fn handles_are_cached_per_kind_and_size() {
|
||||
let first = handle(IconKind::Close, RENDER_PX);
|
||||
let second = handle(IconKind::Close, RENDER_PX);
|
||||
assert_eq!(first.id(), second.id(), "同尺寸同图标必须复用句柄");
|
||||
assert_ne!(first.id(), handle(IconKind::Minimize, RENDER_PX).id());
|
||||
// 同一图标、不同渲染尺寸 = 不同位图(线宽不同),不能共用。
|
||||
assert_ne!(first.id(), handle(IconKind::Close, CAPTION_PX).id());
|
||||
}
|
||||
|
||||
/// **盒 ≠ `RENDER_PX`(24) 的调用点必须走 `view_at`**(复审 r1 P1-1)。
|
||||
///
|
||||
/// 判据 = 调用形状匹配 + **逐调用点计数**(不做「文件里出现过 view_at 就整文件放行」的短路):
|
||||
/// 先剥注释,再认 `icons::view(` / `icons::view::<` / `crate::icons::view` / `super::icons::view`
|
||||
/// 以及 `use …icons…view as v` / `{view, view_at}` 之后的 `v(` / `view(`;
|
||||
/// 每个文件的计数必须**等于**白名单期望(未列出 = 0),所以「同文件改对 3 处、漏掉 1 处」也会红。
|
||||
#[test]
|
||||
fn sub_24_boxes_go_through_view_at() {
|
||||
const VIEW: &[(&str, usize)] = &[
|
||||
("shell/nav.rs", 1),
|
||||
("components/inputs/field.rs", 2),
|
||||
("components/inputs/menu.rs", 2),
|
||||
("components/inputs/search.rs", 2),
|
||||
("components/buttons/mod.rs", 1), // 24 图标 + 40 外壳
|
||||
];
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut views = 0usize;
|
||||
let mut bad: Vec<String> = Vec::new();
|
||||
for path in walk_rs(&root) {
|
||||
let rel = path.strip_prefix(&root).expect("strip_prefix");
|
||||
let rel = rel.to_string_lossy().replace('\\', "/");
|
||||
if rel == "icons.rs" {
|
||||
continue; // 实现自身
|
||||
}
|
||||
let code = strip_comments(&std::fs::read_to_string(&path).expect("读组件源码"));
|
||||
let view_alias = icon_view_aliases(&code);
|
||||
let view = count_calls(&code, "view", &view_alias);
|
||||
views += view;
|
||||
let want_view = sites(VIEW, &rel);
|
||||
if view != want_view {
|
||||
bad.push(format!(
|
||||
"{rel}: {view} 处 `icons::view`,期望 {want_view} —— 盒 ≠ 24 必须 \
|
||||
`icons::view_at(kind, color, 盒尺寸)`(24 烘焙再缩放只有 ~1.1px 线宽)"
|
||||
));
|
||||
}
|
||||
}
|
||||
assert!(bad.is_empty(), "图标入口用法违规:\n{}", bad.join("\n"));
|
||||
assert_eq!(views, 8, "全仓 `icons::view` 调用点总数变了");
|
||||
|
||||
fn sites(list: &[(&str, usize)], rel: &str) -> usize {
|
||||
list.iter().find(|(p, _)| *p == rel).map_or(0, |(_, n)| *n)
|
||||
}
|
||||
fn walk_rs(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
for entry in std::fs::read_dir(dir).expect("读目录") {
|
||||
let path = entry.expect("目录项").path();
|
||||
if path.is_dir() {
|
||||
out.extend(walk_rs(&path));
|
||||
} else if path.extension().is_some_and(|ext| ext == "rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// 剥掉 `//` 行注释与 `/* */` 块注释;字符串里的 `//`(URL)不算注释。
|
||||
fn strip_comments(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut it = text.chars().peekable();
|
||||
let (mut line, mut block, mut string) = (false, false, false);
|
||||
while let Some(c) = it.next() {
|
||||
if line {
|
||||
line = c != '\n';
|
||||
if c == '\n' {
|
||||
out.push(c);
|
||||
}
|
||||
} else if block {
|
||||
if c == '*' && it.peek() == Some(&'/') {
|
||||
it.next();
|
||||
block = false;
|
||||
}
|
||||
} else if string {
|
||||
out.push(c);
|
||||
if c == '\\' {
|
||||
if let Some(esc) = it.next() {
|
||||
out.push(esc);
|
||||
}
|
||||
} else if c == '"' {
|
||||
string = false;
|
||||
}
|
||||
} else if c == '"' {
|
||||
string = true;
|
||||
out.push(c);
|
||||
} else if c == '/' && it.peek() == Some(&'/') {
|
||||
it.next();
|
||||
line = true;
|
||||
} else if c == '/' && it.peek() == Some(&'*') {
|
||||
it.next();
|
||||
block = true;
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `use …icons…view …` 里 `view` 的本地名(含 `as` 别名与花括号列表)。
|
||||
fn icon_view_aliases(code: &str) -> Vec<String> {
|
||||
let mut names = Vec::new();
|
||||
for line in code.lines() {
|
||||
let t = line.trim_start();
|
||||
if !t.starts_with("use ") || !t.contains("icons") {
|
||||
continue;
|
||||
}
|
||||
for (idx, _) in t.match_indices("view") {
|
||||
let end = idx + 4;
|
||||
if !word_start(t, idx) || !word_end(t, end) {
|
||||
continue;
|
||||
}
|
||||
let alias = alias_after(t, end).unwrap_or_else(|| "view".to_owned());
|
||||
if !names.contains(&alias) {
|
||||
names.push(alias);
|
||||
}
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
/// `use …::view as v;` 里的 `v`。
|
||||
fn alias_after(line: &str, from: usize) -> Option<String> {
|
||||
let rest = line[from..].trim_start().strip_prefix("as")?.trim_start();
|
||||
let name: String = rest
|
||||
.chars()
|
||||
.take_while(|c| c.is_alphanumeric() || *c == '_')
|
||||
.collect();
|
||||
(!name.is_empty()).then_some(name)
|
||||
}
|
||||
/// 数调用点:路径形状(`icons::<name>`,含 `crate::`/`super::` 前缀)与别名形状,
|
||||
/// 后面必须紧跟 `(` 或 `::<`。
|
||||
fn count_calls(code: &str, name: &str, aliases: &[String]) -> usize {
|
||||
let mut count = 0;
|
||||
let path = format!("icons::{name}");
|
||||
for (idx, _) in code.match_indices(&path) {
|
||||
if word_start(code, idx) && follows_call(code, idx + path.len()) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
for alias in aliases {
|
||||
for (idx, _) in code.match_indices(alias.as_str()) {
|
||||
let end = idx + alias.len();
|
||||
// `icons::view(` 里的 `view` 已由路径形状数过,别再数一次。
|
||||
if !(idx >= 7 && code[..idx].ends_with("icons::"))
|
||||
&& word_start(code, idx)
|
||||
&& word_end(code, end)
|
||||
&& follows_call(code, end)
|
||||
{
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
fn follows_call(code: &str, from: usize) -> bool {
|
||||
let rest = code[from..].trim_start_matches([' ', '\t']);
|
||||
rest.starts_with('(') || rest.starts_with("::<")
|
||||
}
|
||||
fn word_start(code: &str, idx: usize) -> bool {
|
||||
code[..idx]
|
||||
.chars()
|
||||
.next_back()
|
||||
.is_none_or(|c| !(c.is_alphanumeric() || c == '_'))
|
||||
}
|
||||
fn word_end(code: &str, idx: usize) -> bool {
|
||||
code[idx..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_none_or(|c| !(c.is_alphanumeric() || c == '_'))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
//! | 部件 | 参考 | 令牌落点 |
|
||||
//! |---|---|---|
|
||||
//! | 标题栏高度 | 16.2 桌面标题栏 **40**(登记偏离) | `LM.titlebar_height = 48`(M63 §1.4/R13 桌面档,令牌优先) |
|
||||
//! | 标题栏按钮 | 16.5 图标 **24** | `IconSize::Toolbar` + `LM.titlebar_button_width = 46` |
|
||||
//! | 标题栏按钮 | 16.5 官方图标 **24** → 本项目 **20**(M86 项目偏离,用户点名「图标稍大」) | `icons::CAPTION_PX`(令牌 `TITLEBAR_CAPTION_ICON_PX`)+ `LM.titlebar_button_width = 46` |
|
||||
//! | 页头 | 16.1 官方 AppBar **64**;本项目 **56** | `LM.page_header_height = 56`(已有偏离登记) |
|
||||
//! | 卡片 | 3.x Outlined:**Medium(12)**、L0、`OutlineVariant` 1px、`SurfaceContainerLow` | `Component::OutlinedCard.spec()` + `STROKE_PX` |
|
||||
//! | 字级 | 16.4 标题 `TitleLarge`;卡片 `TitleMedium` / `BodySmall` | `Ui::style` / `Ui::emphasized` |
|
||||
@@ -153,9 +153,13 @@ fn titlebar<'a>(ui: &'a Ui, title: &'a str, forced: Option<ForcedState>) -> Elem
|
||||
|
||||
/// 标题栏按钮(自绘最小化/最大化/关闭)。
|
||||
///
|
||||
/// 图标必须**再包一层固定 24×24 的容器**:iced 的 `button` 在 layout 里给内容 Fill 约束,
|
||||
/// 图标必须**再包一层固定尺寸的容器**:iced 的 `button` 在 layout 里给内容 Fill 约束,
|
||||
/// 直接把 `svg(...).width(24)` 塞进 button,usvg 会按 Contain 撑满内区(实测渲染盒 46 → 视觉线宽
|
||||
/// 从 1.5 变 2.9,视觉复审 P2-2)。Container 尊重子元素的 Length,所以这层是「锁尺寸」用的。
|
||||
///
|
||||
/// **图标尺寸 = [`icons::CAPTION_PX`](20,项目偏离;官方 24),容器 46×48 不动**(M86):
|
||||
/// 用户原话是「保持其容器尺寸不变的前提下把图标改小些」,所以缩放只落在图标盒与 SVG 位图上,
|
||||
/// `metrics.titlebar_button_width` / `titlebar_height` 一个都不许跟着改。
|
||||
fn caption<'a>(
|
||||
ui: &'a Ui,
|
||||
kind: IconKind,
|
||||
@@ -163,15 +167,17 @@ fn caption<'a>(
|
||||
forced: Option<ForcedState>,
|
||||
) -> Element<'a, Message> {
|
||||
let metrics = ui.metrics();
|
||||
// 用 `center(Length::Fill)` 而不是只设 `width(24).height(24)`:后者会让容器缩到 24×24 并贴到
|
||||
// 用 `center(Length::Fill)` 而不是只设 `width(20).height(20)`:后者会让容器缩到 20×20 并贴到
|
||||
// button 内容的左上角(button 不居中「收缩后」的内容,r1 实测图标跑到 cell 左上 (left+11.5, 11.5));
|
||||
// `center(Fill)` = 撑满按钮内容区 + 内容居中,字形仍是 24 盒、命中区仍是 46×48。
|
||||
let icon = container(icons::view::<Message>(
|
||||
// `center(Fill)` = 撑满按钮内容区 + **两轴**居中,字形仍是 20 盒、命中区仍是 46×48(Q21:
|
||||
// 只设 `align_y` 不设 `align_x` 会让图标贴左)。
|
||||
let icon = container(icons::view_at::<Message>(
|
||||
kind,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
icons::CAPTION_PX,
|
||||
))
|
||||
.width(icons::RENDER_PX)
|
||||
.height(icons::RENDER_PX)
|
||||
.width(icons::CAPTION_PX)
|
||||
.height(icons::CAPTION_PX)
|
||||
.center(Length::Fill);
|
||||
|
||||
button(icon)
|
||||
@@ -346,4 +352,29 @@ mod tests {
|
||||
assert_eq!(crate::shot::flavor_slug(&ui(SchemeFlavor::Light)), "light");
|
||||
assert_eq!(crate::shot::flavor_slug(&ui(SchemeFlavor::Dark)), "dark");
|
||||
}
|
||||
|
||||
/// M86:标题栏 caption 图标缩到 20 后,**容器一个数都没动**(46×48),图标也还在框里。
|
||||
#[test]
|
||||
fn caption_icon_shrinks_without_touching_the_container() {
|
||||
let ui = ui(SchemeFlavor::Light);
|
||||
let metrics = ui.metrics();
|
||||
let caption = icons::CAPTION_PX;
|
||||
assert_eq!(metrics.titlebar_button_width, 46.0, "容器宽不许跟着图标改");
|
||||
assert_eq!(metrics.titlebar_height, 48.0, "容器高不许跟着图标改");
|
||||
assert!(
|
||||
caption < metrics.titlebar_button_width,
|
||||
"图标 {caption} 必须小于按钮宽 {}",
|
||||
metrics.titlebar_button_width
|
||||
);
|
||||
assert!(
|
||||
caption < metrics.titlebar_height,
|
||||
"图标 {caption} 必须小于按钮高 {}",
|
||||
metrics.titlebar_height
|
||||
);
|
||||
// 官方口径 24 仍在令牌里(`IconSize::Toolbar`),偏离只发生在 caption 这一个入口。
|
||||
assert!(
|
||||
caption < unlucky_design::icons::IconSize::Toolbar.px(),
|
||||
"caption 图标必须小于官方 24(缩小是用户点名的)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -38,14 +38,53 @@ impl StateLayer {
|
||||
}
|
||||
}
|
||||
|
||||
/// 禁用态的透明度(M3):容器 12%、内容 38%。
|
||||
/// 禁用态的透明度(M3):**按钮家族**容器 10%、内容 38%。
|
||||
///
|
||||
/// 别用「灰化」近似——深色模式下灰化会降低对比度,alpha 叠加两种模式都成立。
|
||||
pub const DISABLED_CONTAINER_ALPHA: f32 = 0.12;
|
||||
///
|
||||
/// ## 取值依据(每个 token 的消费者都用 `javap -c ButtonDefaults` 数过)
|
||||
///
|
||||
/// - **按钮容器 10%**:`FilledButtonTokens` / `ElevatedButtonTokens` / `OutlinedButtonTokens` 的
|
||||
/// `DisabledContainerOpacity = 0.1f`,且 `ButtonDefaults.<clinit>` 里**真的调了**
|
||||
/// `getDisabledContainerOpacity()`(各 1 次)——活令牌。
|
||||
/// `TextButtonTokens` 同值但容器透明(`ButtonDefaults` 只读它的 `DisabledLabel*`);
|
||||
/// `BaselineButtonTokens` 同值,它是**基准表**、运行时零消费者,只作参照。
|
||||
/// - ⚠️ `TonalButtonTokens` 也写 0.1f,但它在**整个 AAR 里零消费者**(`ButtonDefaults` 只引用
|
||||
/// `FilledTonalButtonTokens`;`grep` 命中的 `…TonalButtonTokens` 是后者的子串假象)。
|
||||
/// **别拿它当依据**——这正是本 mission 反复警告的死令牌坑(同 `ButtonSmallTokens.IconSize = 20`)。
|
||||
/// - **面状控件(chip 容器/描边、slider 轨道、switch 轨道)12%** 是另一类令牌,口径在
|
||||
/// `unlucky_app_iced::components::interaction::disabled_colors`。
|
||||
///
|
||||
/// ## ⚠️ 已知偏离:filled-tonal 的禁用容器**官方是 0.12f,我们走 0.10**
|
||||
///
|
||||
/// A 级事实(`javap -p -constants androidx.compose.material3.tokens.FilledTonalButtonTokens`):
|
||||
/// `public static final float DisabledContainerOpacity = 0.12f;`
|
||||
/// ——它是**编译期常量**,被内联进 `ButtonDefaults.getDefaultFilledTonalButtonColors` 的字节码
|
||||
/// (所以那里看到的是 `ldc_w 0.12f`,`getDisabledContainerOpacity()` 根本不会被调用;
|
||||
/// 在 `<clinit>` 里也看不到它——那是「没在 clinit 里出现」,**不是「字段不存在」**)。
|
||||
///
|
||||
/// 我们的实现在 tonal 与 filled 之间**没有区分**,两者共用本常量 ⇒ **tonal 实际画的是 10%**。
|
||||
/// 这是**已登记的偏离,不是对齐**:要修得让 `disabled_button_colors` 知道「哪个变体」,
|
||||
/// 并同步 `gallery/probe` 的逐行断言;本轮(M86)只做令牌一致性收口,不扩到变体分派。
|
||||
///
|
||||
/// 曾经的缺陷不是「12 是错的」,而是**同一个常量同时服务这两类**:按钮那半边一直错、另半边一直对。
|
||||
pub const DISABLED_CONTAINER_ALPHA: f32 = 0.10;
|
||||
/// 禁用态内容(文字/图标)透明度。
|
||||
pub const DISABLED_CONTENT_ALPHA: f32 = 0.38;
|
||||
|
||||
/// 禁用态容器色:`on_surface` 12% 叠在 `surface` 上。
|
||||
/// 禁用态的透明度(M3):**面状控件族**容器与描边 12%。
|
||||
///
|
||||
/// 这一族是「一整面涂上去的容器或用它画出来的描边/轨道」,官方 token 各写一份但值统一是 `0.12f`:
|
||||
/// `AssistChipTokens.FlatDisabledOutlineOpacity`、`FilterChipTokens.FlatDisabledSelectedContainerOpacity`
|
||||
/// / `FlatDisabledUnselectedOutlineOpacity`、`SliderTokens.DisabledInactiveTrackOpacity`、
|
||||
/// `SwitchTokens.DisabledTrackOpacity`(AAR 字节码 `<clinit>`)。
|
||||
///
|
||||
/// **不适用于按钮容器**:按钮是 [`DISABLED_CONTAINER_ALPHA`](10%,`*ButtonTokens.DisabledContainerOpacity`)。
|
||||
/// 更不适用于 TextField 的 Filled 容器——那是 `FilledTextFieldTokens.FieldDisabledContainerOpacity = 0.04f`,
|
||||
/// 我们仍在用 12%(既有 D10 偏离,未在本轮收口)。
|
||||
pub const DISABLED_SURFACE_ALPHA: f32 = 0.12;
|
||||
|
||||
/// 禁用态容器色:`on_surface` 10%(按钮家族的 `DisabledContainerOpacity`)叠在 `surface` 上。
|
||||
pub fn disabled_container(scheme: &super::Scheme) -> Color {
|
||||
scheme
|
||||
.get(super::Role::OnSurface)
|
||||
@@ -93,4 +132,33 @@ mod tests {
|
||||
assert!(ratio > 1.5, "禁用态对比度过低:{ratio}");
|
||||
assert!(ratio < 4.5, "禁用态不该和正文一样醒目:{ratio}");
|
||||
}
|
||||
|
||||
/// 两族 α 的**令牌层钉值**(M86):期望值硬编码,不从被测常量取——否则改坏常量也照样绿。
|
||||
///
|
||||
/// 出处(AAR `material3-android-1.4.0` 的 `<clinit>`):
|
||||
/// `*ButtonTokens.DisabledContainerOpacity = 0.1f`(按钮族)与
|
||||
/// `AssistChipTokens.FlatDisabledOutlineOpacity` / `SliderTokens.DisabledInactiveTrackOpacity` /
|
||||
/// `SwitchTokens.DisabledTrackOpacity = 0.12f`(面状控件族)。
|
||||
#[test]
|
||||
fn disabled_alpha_classes_are_pinned_at_the_token_layer() {
|
||||
const EXPECTED_BUTTON: f32 = 0.10;
|
||||
const EXPECTED_SURFACE: f32 = 0.12;
|
||||
const EXPECTED_CONTENT: f32 = 0.38;
|
||||
let button = DISABLED_CONTAINER_ALPHA;
|
||||
let surface = DISABLED_SURFACE_ALPHA;
|
||||
let content = DISABLED_CONTENT_ALPHA;
|
||||
assert_eq!(
|
||||
button, EXPECTED_BUTTON,
|
||||
"按钮族禁用容器 α 必须是官方 0.1f(*ButtonTokens.DisabledContainerOpacity)"
|
||||
);
|
||||
assert_eq!(
|
||||
surface, EXPECTED_SURFACE,
|
||||
"面状控件族 α 必须是官方 0.12f(chip 描边 / slider 轨道 / switch 轨道)"
|
||||
);
|
||||
assert_eq!(content, EXPECTED_CONTENT, "禁用内容 0.38f");
|
||||
assert!(
|
||||
surface > button,
|
||||
"两族必须可区分({surface} > {button});合成一个数就是 M86 要防的回归"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,8 @@
|
||||
//! `render_px / VIEWBOX` 缩放。同样的 `1.5` 在 20px 渲染下只有 1.25px 视觉宽度。
|
||||
//!
|
||||
//! 因此 iced 侧必须**按 SVG 语义重算**:要保持 1.5px 视觉线宽,viewBox 里的 stroke-width 得取
|
||||
//! `1.5 × 24 / render_px`(20px → 1.8、16px → 2.25、24px → 1.5)。[`Icon::stroke_width`] 就是这个
|
||||
//! 换算的纯函数——**不要在适配层手抄 1.5 这个数**,那是 Slint 口径。
|
||||
//! `1.5 × 24 / render_px`(24px → 1.5、20px → 1.8、18px → 2.0、16px → 2.25)。[`Icon::stroke_width`]
|
||||
//! 就是这个换算的纯函数——**不要在适配层手抄 1.5 这个数**,那是 Slint 口径。
|
||||
|
||||
mod glyphs;
|
||||
mod kinds;
|
||||
@@ -66,15 +66,38 @@ pub enum IconPaint {
|
||||
}
|
||||
|
||||
/// 图标的标准渲染尺寸(页面只从这几档里取,不许自定义零散值)。
|
||||
///
|
||||
/// ## 每一档服务于哪个规格(出处全部是 A 级:`material3-android-1.4.0.aar` 的字节码)
|
||||
///
|
||||
/// 判据顺序固定为:**先看官方组件实际消费哪个值,再看 token 类里写了什么**。两者冲突时以
|
||||
/// 「有消费者的那个」为准——1.4.0 里 `ButtonSmallTokens.IconSize = 20` 就是**零消费者**的死令牌
|
||||
/// (`javap` 全 AAR 搜 `getIconSize` 调用,没有任何类读它;`ButtonDefaults` 只借了同一
|
||||
/// token 类的 `IconLabelSpace` / `ContainerHeight` / `ContainerShapeRound`)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum IconSize {
|
||||
/// 12px:行内小图标(徽标内)。
|
||||
Inline,
|
||||
/// 16px:按钮/标题栏按钮。
|
||||
/// 16px:**控件内微字形**——Switch 滑块内的勾/图标。
|
||||
///
|
||||
/// A 级:`SwitchTokens.SelectedIconSize = 16.0d` / `UnselectedIconSize = 16.0d`。
|
||||
/// 别拿它当按钮图标用:按钮是 18(见 [`IconSize::Button`]),两者在官方是两个 token。
|
||||
SwitchThumb,
|
||||
/// 18px:**按钮内前置/后置图标**,也用于 Chip 图标。
|
||||
///
|
||||
/// A 级:`ButtonDefaults.<clinit>` 里 `bipush 18` → `IconSize = 18.dp`(12b 1.6 引的
|
||||
/// `ButtonDefaults.IconSize=18` 就是这一行);`AssistChipTokens.IconSize = 18.0d`(12b 13.4)。
|
||||
/// 死令牌说明:`BaselineButtonTokens.IconSize` / `ButtonSmallTokens.IconSize` 都写 20,
|
||||
/// 但 AAR 里没有任何类调用它们的 `getIconSize`(`FilledTonalButtonTokens` 反而是 18)。
|
||||
Button,
|
||||
/// 20px:导航项、列表行。
|
||||
/// 20px:**目录档,当前无消费**。
|
||||
///
|
||||
/// ⚠️ 别按名字拿它当导航/列表图标:官方导航轨与列表行的图标是 **24**
|
||||
/// (`AppBarTokens.IconSize = 24.0d`、12b 5.5 / 4.4),实现侧也一直是 24([`IconSize::Toolbar`])。
|
||||
/// 标题栏 caption 按钮的 20 不是这一档,见 [`TITLEBAR_CAPTION_ICON_PX`](那是项目偏离)。
|
||||
Nav,
|
||||
/// 24px:卡片头、工具栏。
|
||||
/// 24px:卡片头、工具栏、IconButton、标题栏 caption(官方口径)。
|
||||
///
|
||||
/// A 级:`SmallIconButtonTokens.IconSize = 24.0d`(12b 2.2 / 16.5)、`AppBarTokens.IconSize = 24.0d`。
|
||||
Toolbar,
|
||||
/// 32px:空态图标块。
|
||||
Empty,
|
||||
@@ -85,7 +108,8 @@ impl IconSize {
|
||||
pub const fn px(self) -> f32 {
|
||||
match self {
|
||||
IconSize::Inline => 12.0,
|
||||
IconSize::Button => 16.0,
|
||||
IconSize::SwitchThumb => 16.0,
|
||||
IconSize::Button => 18.0,
|
||||
IconSize::Nav => 20.0,
|
||||
IconSize::Toolbar => 24.0,
|
||||
IconSize::Empty => 32.0,
|
||||
@@ -93,8 +117,9 @@ impl IconSize {
|
||||
}
|
||||
|
||||
/// 全部档位,由小到大。
|
||||
pub const ALL: [IconSize; 5] = [
|
||||
pub const ALL: [IconSize; 6] = [
|
||||
IconSize::Inline,
|
||||
IconSize::SwitchThumb,
|
||||
IconSize::Button,
|
||||
IconSize::Nav,
|
||||
IconSize::Toolbar,
|
||||
@@ -102,6 +127,17 @@ impl IconSize {
|
||||
];
|
||||
}
|
||||
|
||||
/// 标题栏 caption 按钮(最小化 / 最大化 / 关闭)的图标边长——**用户点名的项目偏离**。
|
||||
///
|
||||
/// 官方 `SmallIconButtonTokens.IconSize = 24.0d`(AAR 字节码,12b 16.5 同值),我们的 24
|
||||
/// **本来就是对的**,所以这条不是「对齐规范」,是业主主观调整:用户原话「右上角三个金刚键的
|
||||
/// 图标显示稍大了些,能不能在**保持其容器尺寸不变**的前提下把图标改小些」。
|
||||
///
|
||||
/// 约束:**容器不动**(46×48 仍来自 `LayoutMetrics::titlebar_button_width` /
|
||||
/// `titlebar_height`),只缩图标;且这条**只适用于 caption 三键**,不许扩散到导航/卡片/表单的
|
||||
/// 24px 图标(那些是 12b 2.2 / 4.4 / 5.5 / 9.6 / 16.5 的判据)。
|
||||
pub const TITLEBAR_CAPTION_ICON_PX: f32 = 20.0;
|
||||
|
||||
/// 一个图标的几何数据。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Icon {
|
||||
@@ -248,4 +284,59 @@ mod tests {
|
||||
assert!(!IconKind::Plus.icon().has_curves());
|
||||
assert!(!IconKind::ChevronDown.icon().has_curves());
|
||||
}
|
||||
|
||||
/// 每一档的像素值都被**钉在官方出处**上:改值必须同时改这条测试,改的人会看到出处。
|
||||
///
|
||||
/// 这不是「断言一个常量等于它自己」——它断言的是「我们读到的官方值」,
|
||||
/// 所以下一个人按 `research/12b` 或按某个 token 类把 18 改回 16/20 时,这条会红并打印出处。
|
||||
#[test]
|
||||
fn icon_sizes_are_pinned_to_their_official_sources() {
|
||||
// A 级:ButtonDefaults.<clinit> 的 `bipush 18` → IconSize = 18.dp
|
||||
// (`AssistChipTokens.IconSize = 18.0d` 同值)。
|
||||
assert_eq!(
|
||||
IconSize::Button.px(),
|
||||
18.0,
|
||||
"按钮/Chip 图标 18:AAR ButtonDefaults.IconSize(12b 1.6 同值)。\
|
||||
ButtonSmallTokens.IconSize=20 是零消费者的死令牌,不要按它改回去"
|
||||
);
|
||||
// A 级:SwitchTokens.SelectedIconSize / UnselectedIconSize = 16.0d
|
||||
assert_eq!(
|
||||
IconSize::SwitchThumb.px(),
|
||||
16.0,
|
||||
"Switch 滑块图标 16:AAR SwitchTokens.Selected/UnselectedIconSize"
|
||||
);
|
||||
// A 级:SmallIconButtonTokens.IconSize / AppBarTokens.IconSize = 24.0d
|
||||
assert_eq!(
|
||||
IconSize::Toolbar.px(),
|
||||
24.0,
|
||||
"IconButton/标题栏/卡片头 24:AAR SmallIconButtonTokens / AppBarTokens"
|
||||
);
|
||||
// 目录档(当前无消费):不许被当成 nav 图标用(nav 是 24)。
|
||||
assert_eq!(IconSize::Nav.px(), 20.0, "目录档 20(无消费)");
|
||||
assert_eq!(IconSize::Inline.px(), 12.0);
|
||||
assert_eq!(IconSize::Empty.px(), 32.0);
|
||||
}
|
||||
|
||||
/// 标题栏 caption 的 20 是**项目偏离**,不是规范值;这条把「偏离的是哪两个数」钉住。
|
||||
#[test]
|
||||
fn titlebar_caption_icon_is_a_registered_deviation() {
|
||||
assert_eq!(
|
||||
TITLEBAR_CAPTION_ICON_PX, 20.0,
|
||||
"用户点名的偏离:官方 SmallIconButtonTokens.IconSize = 24,caption 三键缩到 20"
|
||||
);
|
||||
// 绑定成局部变量再过断言:直接比两个常量会被 clippy 判成「常量断言」(assertions_on_constants)。
|
||||
let caption = TITLEBAR_CAPTION_ICON_PX;
|
||||
let official = IconSize::Toolbar.px();
|
||||
assert!(
|
||||
caption < official,
|
||||
"偏离方向是「更小」:{caption} 应 < 官方 {official};若哪天反了,那就不是用户要的那件事了"
|
||||
);
|
||||
// 20px 渲染时视觉线宽仍是 1.5(SVG 语义),这条防「缩图标顺手把线宽也带细」。
|
||||
let stroke = stroke_width_for(caption, VISUAL_STROKE_PX);
|
||||
assert!(
|
||||
(stroke - 1.8).abs() < 1.0e-6,
|
||||
"20px 渲染应取 1.8 个 viewBox 单位"
|
||||
);
|
||||
assert!((visual_stroke_px(caption, stroke) - VISUAL_STROKE_PX).abs() < 1.0e-6);
|
||||
}
|
||||
}
|
||||
@@ -125,16 +125,34 @@ fn icon_kind_is_frozen_and_append_only() {
|
||||
|
||||
#[test]
|
||||
fn icon_stroke_follows_svg_semantics_not_slint() {
|
||||
// Slint 口径(绝对 1.5px)在 20px 渲染下会得到 1.5px 视觉线宽;SVG 口径要写 1.8。
|
||||
// 派生式(**这条测试钉的就是它**):`stroke = 1.5 × 24 / render_px`,1.5 是视觉线宽、24 是 viewBox。
|
||||
//
|
||||
// 两个锚点,一个不随本次收敛变化、一个随令牌取值:
|
||||
// - 20px(`IconSize::Nav`,本轮没动)→ 1.5 × 24 / 20 = **1.8**
|
||||
// - 18px(`IconSize::Button`,M86 由 16 收敛到官方 18)→ 1.5 × 24 / 18 = **2.0**
|
||||
//
|
||||
// ⚠️ **这条只保证「烘焙路径」**(`crates/app-iced/src/icons.rs::view_at`:按盒尺寸烘焙 SVG,
|
||||
// 于是一个 viewBox 单位的 stroke 落到盒子后正好是 1.5 视觉像素)。
|
||||
// 它**不**保证「24 烘焙 + 布局缩到 18」那条路径——那条会得到 `1.5 × 18/24 = 1.125px`,
|
||||
// 正是复审 r1 P2-2 实测到的 1.04~1.25px 缺陷。所以组件侧凡是盒 ≠ `RENDER_PX`(24) 的调用点
|
||||
// 都必须走 `view_at`(见 `icons.rs::tests::view_at_bakes_the_requested_size` 与
|
||||
// `crates/app-iced/src/components/gate.rs` 的 Q9 扫描)。
|
||||
//
|
||||
// 之所以写死字面量而不写成 `stroke_width_for(IconSize::Nav.px(), 1.5)`:后者等于拿实现算实现,
|
||||
// 派生式一旦被改坏(例如把 ×24 写成 ×20)它照样绿。写死才会红。
|
||||
// Slint 口径(绝对 1.5px,不乘 viewBox)在这两档都会得到 1.5 —— 那正是这条要防的错。
|
||||
let nav_width = IconKind::Settings.icon().stroke_width(IconSize::Nav.px());
|
||||
assert!(
|
||||
(nav_width - 1.8).abs() < 1.0e-6,
|
||||
"20px 渲染应取 1.8 个 viewBox 单位"
|
||||
"20px 渲染应取 1.8 个 viewBox 单位(锚点,不随 IconSize 收敛变化)"
|
||||
);
|
||||
let button_width = IconKind::Settings
|
||||
.icon()
|
||||
.stroke_width(IconSize::Button.px());
|
||||
assert!((button_width - 2.25).abs() < 1.0e-6);
|
||||
assert!(
|
||||
(button_width - 2.0).abs() < 1.0e-6,
|
||||
"18px 渲染应取 2.0 个 viewBox 单位(M86:Button 由 16→18,旧期望 2.25 是 16px 的派生值)"
|
||||
);
|
||||
// 视觉宽度的往返一致性(这条是「别照抄 Slint 的 1.5」的守门测试)。
|
||||
for size in IconSize::ALL {
|
||||
let w = stroke_width_for(size.px(), 1.5);
|
||||
|
||||
Reference in new issue
Block a user