refactor(app-iced): M75 r4 —— interaction 拆成 mod.rs(287) + tracker.rs(159),恢复被压掉的文档
interaction.rs 连压三笔才回到 400 行(靠删文档腾空间),而它会被四条家族线各扩一次。 - interaction/mod.rs:五态枚举 + overlay_color / disabled_colors / disabled_content_over(态→颜色) - interaction/tracker.rs:事件→态的状态机 + 它的两条单测;Tracker 用法示例(ignore 代码块)恢复 - 对外路径不变:components::interaction::* 照旧(mod.rs 里再导出 Tracker) - 两份文件都写了「加东西前先看行数余额」 顺带修两处门禁:注释里去掉 hex 色值(守门连注释一起扫)、再导出显式 allow。
This commit is contained in:
1 parent
ab5f5802a8
commit
a878fc6a58
3 files changed
+180
-137
No files matched your search
@@ -0,0 +1,288 @@
|
||||
//! 交互状态层: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.12 | [`DISABLED_CONTAINER_ALPHA`](A1 是 0.10,差异登记 D8) |
|
||||
//! | disabled 内容 | 0.38 | [`DISABLED_CONTENT_ALPHA`] |
|
||||
//!
|
||||
//! 上表右列是**唯一**允许的取值口;左列只是给人看的,代码里一个数字都不许出现
|
||||
//! (`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::state::{DISABLED_CONTAINER_ALPHA, DISABLED_CONTENT_ALPHA};
|
||||
use unlucky_design::color::{Role, Scheme, StateLayer};
|
||||
|
||||
use crate::theme::{to_iced, 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,
|
||||
/// 禁用:容器 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`,中性表面上的控件用 `OnSurface`)。
|
||||
pub fn overlay_color(ui: &Ui, state: Interaction, content: Role, container: Role) -> Option<Color> {
|
||||
state
|
||||
.layer()
|
||||
.map(|layer| ui.state_over(layer, content, container))
|
||||
}
|
||||
|
||||
/// 禁用态内容色:`OnSurfaceVariant` @ 38% 叠在 **`base` 角色**上(不经过 12% 容器)。
|
||||
///
|
||||
/// 给**不画容器**的三式(Text / Outlined / standard IconButton)用:错用 [`disabled_colors`]
|
||||
/// 的内容值(叠在 12% 容器上)会比令牌口径深 21/255。
|
||||
pub fn disabled_content_over(ui: &Ui, base: Role) -> Color {
|
||||
let scheme = Scheme::baseline(ui.flavor());
|
||||
// A1(AAR `FilledButtonTokens` javap):DisabledLabelTextColor/DisabledIconColor 都是
|
||||
// `OnSurfaceVariant`、Opacity 0.38f。设计层的 `disabled_content()` 用 OnSurface(与 A1 不符),
|
||||
// 令牌线修好前这里按 A1 用角色 + 令牌 α 直接求值(不引入字面量)。
|
||||
to_iced(
|
||||
scheme
|
||||
.get(Role::OnSurfaceVariant)
|
||||
.with_alpha(DISABLED_CONTENT_ALPHA)
|
||||
.over(scheme.get(base)),
|
||||
)
|
||||
}
|
||||
|
||||
/// 禁用态的一对颜色 `(容器, 内容)`,内容按令牌口径叠在**算好的容器**之上。
|
||||
///
|
||||
/// - 容器 = `OnSurface` @ 12%([`DISABLED_CONTAINER_ALPHA`])叠在 `surface` 上;
|
||||
/// - 内容 = `OnSurfaceVariant` @ 38%(A1:`FilledButtonTokens.DisabledLabelTextColor`)叠在容器上。
|
||||
///
|
||||
/// `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);
|
||||
// 内容角色按 A1 用 `OnSurfaceVariant`(见 `disabled_content_over` 的说明)
|
||||
let content = scheme
|
||||
.get(Role::OnSurfaceVariant)
|
||||
.with_alpha(DISABLED_CONTENT_ALPHA)
|
||||
.over(container);
|
||||
|
||||
(to_iced(container), to_iced(content))
|
||||
}
|
||||
|
||||
/// 自定义 `Widget` 的状态机:把 iced 事件折成 [`Interaction`]。
|
||||
///
|
||||
#[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 的 α 全表:**钉住令牌值**(改令牌会让这条红,改组件抓不到)。
|
||||
#[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]
|
||||
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 逐像素一致)");
|
||||
}
|
||||
|
||||
#[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, "禁用内容也是实色");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! 自定义 `Widget` 的状态机([`Tracker`]):把 iced 事件折成 [`Interaction`] 五态。
|
||||
//!
|
||||
//! 与 [`super`] 的分工:`super` 只有「态 → 颜色」的纯映射;**事件 → 态**在这里。
|
||||
//! 本 mission 的按钮家族全走 iced 的 `button`(自带四态),所以 `Tracker` 目前只有单测在用——
|
||||
//! 它给四个后续家族里**要自绘**的控件(Slider 轨道 / Switch 轨道 / 进度条)准备。
|
||||
//!
|
||||
//! 加东西前先看行数余额(单文件 ≤400,AGENTS.md §3-1)。
|
||||
|
||||
use iced::Event;
|
||||
|
||||
use super::Interaction;
|
||||
|
||||
/// 自定义 `Widget` 的状态机:把 iced 事件折成 [`Interaction`]。
|
||||
///
|
||||
/// 这是 D39 §3 说的「状态机的公共部分」。用法(自定义 Widget 的 `update` 里):
|
||||
///
|
||||
/// ```ignore
|
||||
/// if self.tracker.event(event, cursor.is_over(layout.bounds())) {
|
||||
/// shell.request_redraw(); // 只有真变了才重绘
|
||||
/// }
|
||||
/// let style = style_of(self.tracker.state());
|
||||
/// ```
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct Tracker {
|
||||
enabled: bool,
|
||||
hovered: bool,
|
||||
focused: bool,
|
||||
pressed: bool,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Tracker {
|
||||
/// 新建;`enabled` 由组件自己决定(例如 `on_press.is_some()`)。
|
||||
pub fn new(enabled: bool) -> Self {
|
||||
Self {
|
||||
enabled,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 启用/禁用热切换(禁用时清掉悬停与按下:禁用元素不该留着 hover 高亮)。
|
||||
pub fn set_enabled(&mut self, enabled: bool) -> bool {
|
||||
let changed = self.enabled != enabled;
|
||||
self.enabled = enabled;
|
||||
if !enabled {
|
||||
self.hovered = false;
|
||||
self.pressed = false;
|
||||
self.focused = false;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// 键盘焦点(由 `operation`/`shell` 侧的聚焦消息驱动)。
|
||||
pub fn set_focused(&mut self, focused: bool) -> bool {
|
||||
let next = focused && self.enabled;
|
||||
let changed = self.focused != next;
|
||||
self.focused = next;
|
||||
changed
|
||||
}
|
||||
|
||||
/// 折进一个事件;返回「视觉态变了,要重绘吗」。
|
||||
///
|
||||
/// `over` = 指针是否在控件 bounds 内(`Cursor::is_over(bounds)` 的结果,由调用方算——
|
||||
/// 这样本函数不依赖 `Layout`,可以在单测里直接喂)。
|
||||
pub fn event(&mut self, event: &Event, over: bool) -> bool {
|
||||
if !self.enabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
let before = self.state();
|
||||
match event {
|
||||
Event::Mouse(iced::mouse::Event::CursorMoved { .. }) => self.hovered = over,
|
||||
Event::Mouse(iced::mouse::Event::CursorLeft) => {
|
||||
self.hovered = false;
|
||||
self.pressed = false;
|
||||
}
|
||||
Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left)) => {
|
||||
self.pressed = over;
|
||||
}
|
||||
Event::Mouse(iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left)) => {
|
||||
self.pressed = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.state() != before
|
||||
}
|
||||
|
||||
/// 当前态(优先级:按下 > 焦点 > 悬停 > 静息;禁用最高)。
|
||||
pub const fn state(&self) -> Interaction {
|
||||
if !self.enabled {
|
||||
Interaction::Disabled
|
||||
} else if self.pressed {
|
||||
Interaction::Pressed
|
||||
} else if self.focused {
|
||||
Interaction::Focused
|
||||
} else if self.hovered {
|
||||
Interaction::Hovered
|
||||
} else {
|
||||
Interaction::Enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Tracker` 的行为用例(自绘控件用,与颜色无关)。
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use iced::Event;
|
||||
|
||||
fn moved() -> Event {
|
||||
Event::Mouse(iced::mouse::Event::CursorMoved {
|
||||
position: iced::Point::ORIGIN,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracker_folds_events_into_the_five_states() {
|
||||
let mut tracker = Tracker::new(true);
|
||||
assert_eq!(tracker.state(), Interaction::Enabled);
|
||||
|
||||
assert!(tracker.event(&moved(), true), "首次悬停要报变化");
|
||||
assert_eq!(tracker.state(), Interaction::Hovered);
|
||||
assert!(!tracker.event(&moved(), true), "重复悬停不该再报变化");
|
||||
|
||||
let press = Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left));
|
||||
assert!(tracker.event(&press, true));
|
||||
assert_eq!(tracker.state(), Interaction::Pressed);
|
||||
|
||||
let release = Event::Mouse(iced::mouse::Event::ButtonReleased(
|
||||
iced::mouse::Button::Left,
|
||||
));
|
||||
assert!(tracker.event(&release, true));
|
||||
assert_eq!(tracker.state(), Interaction::Hovered);
|
||||
|
||||
assert!(tracker.set_focused(true));
|
||||
assert_eq!(tracker.state(), Interaction::Focused, "焦点态优先于悬停");
|
||||
|
||||
let left = Event::Mouse(iced::mouse::Event::CursorLeft);
|
||||
tracker.event(&left, false);
|
||||
assert_eq!(tracker.state(), Interaction::Focused, "鼠标离开不影响焦点");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_tracker_swallows_everything() {
|
||||
let mut tracker = Tracker::new(true);
|
||||
assert!(tracker.set_enabled(false));
|
||||
assert_eq!(tracker.state(), Interaction::Disabled);
|
||||
|
||||
let press = Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left));
|
||||
assert!(!tracker.event(&press, true), "禁用态不响应事件");
|
||||
assert_eq!(tracker.state(), Interaction::Disabled);
|
||||
assert!(!tracker.set_focused(true), "禁用态拿不到焦点");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user