fix(design,app-iced): 图标尺寸与禁用 α 收敛到官方字节码,两族拆开(M86)

图标:IconSize::Button 16→18(AAR ButtonDefaults.<clinit> 的 bipush 18);新增
IconSize::SwitchThumb=16(SwitchTokens.Selected/UnselectedIconSize)承接开关滑块,按钮与
滑块不再借同一档;IconSize::Nav 的值不动但文档订正(无消费,官方导航/列表图标是 24)。
ButtonSmallTokens.IconSize=20 在整包 AAR 里零消费(javap 搜 getIconSize 无外部调用),
是死令牌,不再作为取值依据。

禁用 α:官方本来就分两族——按钮容器 0.10(*ButtonTokens.DisabledContainerOpacity)与
面状控件 0.12(chip 描边 / slider 轨道 / switch 轨道)。原来的缺陷是一个全局 12% 同时
服务两族(按钮那半边一直错、另半边一直对),现拆成 disabled_button_colors/…_content
与 disabled_colors/disabled_content_over_role 两个入口;interaction 首次超 400 行,
禁用口径与测试搬到 interaction/disabled.rs。

标题栏 caption 三键:官方 SmallIconButtonTokens.IconSize=24,按用户点名缩到 20
(容器 46×48 不动),登记为项目偏离;icons.rs 加按令牌取尺寸的入口 view_at,按
(种类,尺寸) 分别烘焙 SVG,20px 的 stroke 取 1.8 保视觉线宽 1.5px。
This commit is contained in:
AskaEth committed 2026-11-02 13:12:11 +08:00
1 parent c825e1aa3d
commit b566097107
16 files changed
+664 -175

No files matched your search

@@ -0,0 +1,275 @@
//! 禁用态的颜色口径:**两个族、两套 α**(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`] 的口径,行为未变。
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 描边)。
///
/// `disabled_colors` 的名字是历史遗留,语义是这一族。谁把 12% 改成 10%,这条红。
#[test]
fn surface_family_disabled_colors_are_the_12_percent_class() {
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(DISABLED_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(DISABLED_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(DISABLED_CONTENT_ALPHA)
.over(base)
),
"{flavor:?}:内容必须叠在容器上"
);
}
}
/// **按钮家族**的那一对颜色 = 0.10 预混合,且与面状控件族**必须落在不同像素值上**。
///
/// 这条是「拆族真的发生了」的判据:把 `disabled_button_colors` 改回 0.12,它在
/// `expected` 与 `assert_ne!` 上同时红;两个入口合并回一个常量,也只有它红。
#[test]
fn button_family_disabled_colors_are_the_10_percent_class() {
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(DISABLED_CONTAINER_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(DISABLED_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, "禁用内容也是实色");
}
}