Files
Unlucky-Launcher/crates/app-iced/src/components/interaction/mod.rs
T
AskaEth d517f28140
Release / 选槽位(默认两个 Windows) (push) Canceled after 0s
Release / build (${{ matrix.slot }}) (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Unlucky Launcher v0.2.1
Single-commit source snapshot of the v0.2.1 release.
2026-11-06 12:35:56 +08:00

240 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 交互状态层:hover / focus / pressed / disabled 的**唯一实现点**(D39 §3)。
//!
//! ## 为什么必须只有一处
//!
//! M3 的状态层不是「另一种灰」,而是**内容色自身 + 固定 alpha** 叠在容器色上
//! (`unlucky_design::color::state` 的文档)。五个家族各写一遍
//! `if hovered { 0.08 }`,第一周就会出现 0.08 / 0.1 / 0.12 三种 hover——
//! 这一层存在的意义就是让「组件里出现 α 字面量」变成不可能:
//!
//! | 态 | α | 令牌来源 |
//! |---|---|---|
//! | hover | 0.08 | [`StateLayer::Hover`] |
//! | focus | 0.10 | [`StateLayer::Focus`] |
//! | pressed | 0.10 | [`StateLayer::Pressed`] |
//! | dragged | 0.16 | [`StateLayer::Dragged`](按钮家族不用,列表拖拽用) |
//! | 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` 会扫)。
//!
//! ## 两个消费形态
//!
//! 1. **iced `button` 系**(本 mission 的按钮家族):拿 [`Interaction`] 直接配 [`overlay_color`];
//! 2. **自定义 `Widget`**(后续若需要自绘轨道/滑块):用 [`Tracker`] 把事件折成五态,
//! `update()` 的返回值告诉你「要不要重绘」,`state()` 给出当前态。
//!
//! ## 与 `Ui::state_over` 的关系
//!
//! 颜色一律经 [`crate::theme::Ui::state_over`] 求值(它返回**不透明**结果,所以 GPU/CPU 两档
//! 逐像素一致,D27)。本模块只做「哪一态叠哪一层」的映射,不做任何颜色算术。
use iced::widget::button;
use iced::Color;
use unlucky_design::color::{Role, StateLayer};
use crate::theme::Ui;
mod tracker;
// 本 mission 没有自绘控件,所以这条再导出在 crate 内暂时无人使用;
// 它是给四条家族线(Slider / Switch 轨道要自绘)的公共入口,路径与拆分前一致。
#[allow(unused_imports)]
pub use tracker::Tracker;
// 本模块会被四条家族线各扩一次:加东西前先看行数余额(单文件 ≤400,AGENTS.md §3-1)。
// `mod.rs` 只装「五态 + 颜色映射」这类纯函数;事件 → 态的**状态机在 [`tracker`]**,
// 需要新的状态机就加到那边,别把本文件再撑到红线上。
/// 一个控件的视觉态(五态,M3 的完整集合)。
///
/// `Enabled` 是**静息态**(不叠状态层);`Focused` / `Pressed` 在 M3 里 α 相同(都 10%),
/// 所以两者在图上会一模一样——这是规范,不是漏画(12b §1.8 判据只要求 α 对得上)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Interaction {
/// 静息:可交互,不叠状态层。
Enabled,
/// 悬停:叠 8%。
Hovered,
/// 键盘焦点:叠 10%。
Focused,
/// 按下:叠 10%。
Pressed,
/// 禁用:按钮容器 10% / 面状控件 12% / 内容 38%,**不叠状态层**。
Disabled,
}
impl Interaction {
/// 五态全序(gallery 的状态矩阵按这个次序出图)。
pub const ALL: [Interaction; 5] = [
Interaction::Enabled,
Interaction::Hovered,
Interaction::Pressed,
Interaction::Focused,
Interaction::Disabled,
];
/// 稳定名字(截图文件名 / 图例 / 测试失败信息用)。
pub const fn name(self) -> &'static str {
match self {
Interaction::Enabled => "enabled",
Interaction::Hovered => "hovered",
Interaction::Focused => "focused",
Interaction::Pressed => "pressed",
Interaction::Disabled => "disabled",
}
}
/// 是否禁用(禁用不是「第六态」,它要不要画容器由组件决定)。
pub const fn is_disabled(self) -> bool {
matches!(self, Interaction::Disabled)
}
/// 该态要叠的状态层;`None` = 不叠(静息与禁用)。
pub const fn layer(self) -> Option<StateLayer> {
match self {
Interaction::Enabled | Interaction::Disabled => None,
Interaction::Hovered => Some(StateLayer::Hover),
Interaction::Focused => Some(StateLayer::Focus),
Interaction::Pressed => Some(StateLayer::Pressed),
}
}
/// 从 iced 的 [`button::Status`] 推导(`button` 只有四态,没有 focus)。
///
/// 用 `button` 做组件的家族一律经这里,**不许自己 match status 再各写一遍颜色**。
pub const fn from_status(status: button::Status) -> Self {
match status {
button::Status::Active => Interaction::Enabled,
button::Status::Hovered => Interaction::Hovered,
button::Status::Pressed => Interaction::Pressed,
button::Status::Disabled => Interaction::Disabled,
}
}
}
/// 当前态叠在 `container` 上的**不透明**颜色;`None` = 该态不叠。
///
/// `content` 是容器上的前景角色(Filled 按钮用 `OnPrimary`;中性表面上的控件用 `OnSurfaceVariant`)。
pub fn overlay_color(ui: &Ui, state: Interaction, content: Role, container: Role) -> Option<Color> {
state
.layer()
.map(|layer| ui.state_over(layer, content, container))
}
mod disabled;
pub use disabled::{
disabled_button_colors, disabled_button_content, disabled_colors, disabled_content_over_role,
};
#[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)
}
/// 契约 §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);
}
#[test]
fn five_states_map_onto_four_layers() {
assert_eq!(Interaction::ALL.len(), 5);
assert_eq!(Interaction::Enabled.layer(), None);
assert_eq!(Interaction::Disabled.layer(), None);
assert_eq!(Interaction::Hovered.layer(), Some(StateLayer::Hover));
assert_eq!(Interaction::Focused.layer(), Some(StateLayer::Focus));
assert_eq!(Interaction::Pressed.layer(), Some(StateLayer::Pressed));
// focus 与 pressed 同 α(M3 口径)——图上一样是**对的**
assert_eq!(
Interaction::Focused.layer().map(StateLayer::alpha),
Interaction::Pressed.layer().map(StateLayer::alpha)
);
}
#[test]
fn overlay_color_is_the_token_blend_not_local_math() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let ui = ui(flavor);
for (state, layer) in [
(Interaction::Hovered, StateLayer::Hover),
(Interaction::Focused, StateLayer::Focus),
(Interaction::Pressed, StateLayer::Pressed),
] {
assert_eq!(
overlay_color(&ui, state, Role::OnPrimary, Role::Primary),
Some(ui.state_over(layer, Role::OnPrimary, Role::Primary)),
"{flavor:?}/{state:?} 必须逐位等于令牌求值结果"
);
assert!(
overlay_color(&ui, state, Role::OnSurface, Role::Surface).is_some(),
"可交互态必须给颜色"
);
}
assert_eq!(
overlay_color(&ui, Interaction::Enabled, Role::OnSurface, Role::Surface),
None
);
assert_eq!(
overlay_color(&ui, Interaction::Disabled, Role::OnSurface, Role::Surface),
None,
"禁用态不叠状态层(它换的是容器/内容色)"
);
}
}
#[test]
fn hover_is_weaker_than_pressed_on_the_same_container() {
let ui = ui(SchemeFlavor::Light);
let hover = overlay_color(&ui, Interaction::Hovered, Role::OnSurface, Role::Surface)
.expect("hover 有颜色");
let pressed = overlay_color(&ui, Interaction::Pressed, Role::OnSurface, Role::Surface)
.expect("pressed 有颜色");
assert_ne!(hover, pressed, "8% 与 10% 必须能区分");
assert_eq!(hover.a, 1.0, "结果必须不透明(GPU/CPU 逐像素一致)");
}
}