feat(motion2): 右栗折叠过渡 + 状态层 hover/pressed 过渡
A2 侧边栏(右栗 AI 栏)折叠/展开: - 帧泵第三条轨道(pump/rail):展开 medium1 250ms + Emphasized、收起 short4 200ms + EmphasizedAccelerate;拆出档走 snap(不做过渡);MotionScale::NONE 下不插轨道 - 宽度在 metrics::rail_width 逐帧插值;面板内容按目标宽排版 + 按动画宽裁切 (不重排 / 不溢出),宽度回到 48 时换成图标条(图标贴右端,位置不跳) - pages/agent/mod.rs 的展开档面板纯搬移到 panel.rs(371 → 289 行) A8 状态层过渡: - interaction/layer:令牌化的进度时钟(short1 50ms),NONE 当帧落终值 - interaction/hover:取样控件把起跑搬到 Widget::update(早于订阅表求值) - 按钮五式 + IconButton + 导航项药丸统一走 interaction::layer_color (没有取样控件时逐位退回旧口径,出图基线不变) - components/motion/mod.rs 的 needs_frames / advance 各加一句委托, 帧订阅仍然只有一处判断点(app/subscription.rs 未改)
This commit is contained in:
1 parent
c4706c759c
commit
b61f342431
22 files changed
+1837
-145
No files matched your search
@@ -0,0 +1,226 @@
|
||||
//! **hover / pressed 的取样控件**(M257 / A8):把「状态变了」这件事搬到 `update` 侧。
|
||||
//!
|
||||
//! ## 为什么需要它(一句话)
|
||||
//!
|
||||
//! `button` 的 hover 状态只在 `style` 闭包里可见,而闭包在 **`draw`** 里跑——那已经在订阅表
|
||||
//! 求值之后了。若在 draw 侧起跑过渡,「鼠标停住不动」的整段时间里**一帧都不会来**,状态层会永远
|
||||
//! 停在 `progress = 0`(看不见)。本控件用 `Widget::update`(早于订阅表求值)把目标态写进
|
||||
//! [`super::layer`],于是同一拍 `needs_frames()` 就变真。
|
||||
//!
|
||||
//! ## 它做什么 / 不做什么
|
||||
//!
|
||||
//! | 做 | 不做 |
|
||||
//! |---|---|
|
||||
//! | 用 [`Tracker`] 把鼠标事件折成五态 | **不**消费事件(`shell` 的捕获位不动,按钮照常收到点击) |
|
||||
//! | 改自己 `Tree` 状态里的 [`Layer`] 目标 | **不**改颜色、不改布局(颜色仍由 `style` 闭包求值) |
|
||||
//! | `draw` 时把自己的层压到栈顶([`layer::with_current`]) | **不**包装禁用控件(没有状态层可动) |
|
||||
//!
|
||||
//! ## 命中测试用的是**布局矩形**
|
||||
//!
|
||||
//! `cursor.is_over(layout.bounds())`:与 `button` 自己的判定同源(它也读布局矩形)。触摸板的
|
||||
//! 「按下后拖出再拖回」在 `Tracker` 里按 `pressed` 跟随 `over` 折,与 iced 的 `button` 语义一致。
|
||||
//!
|
||||
//! ## 抓不住什么
|
||||
//!
|
||||
//! 覆盖在按钮**之上**的另一层(模态遮罩 / tooltip)不算:本控件只看自己的矩形,而 `button`
|
||||
//! 的 hover 会被上层遮住的行为由 iced 的 overlay 决定。两者在这类遮挡下可能短暂不一致
|
||||
//! (按钮自己认为被 hover、本控件也认为被 hover),但既然按钮被遮住就点不到,观感无差。
|
||||
|
||||
use iced::advanced::layout::{self, Layout};
|
||||
use iced::advanced::renderer;
|
||||
use iced::advanced::widget::{tree, Tree};
|
||||
use iced::advanced::{mouse, Clipboard, Shell, Widget};
|
||||
use iced::{Element, Event, Length, Rectangle, Size, Vector};
|
||||
|
||||
use super::layer::{self, Layer};
|
||||
use super::Tracker;
|
||||
use crate::app::components::motion;
|
||||
|
||||
/// 包一层「状态取样」:`enabled == false` 时**完全不跟踪**(禁用控件没有状态层)。
|
||||
pub fn tracked<'a, Message, Theme, Renderer>(
|
||||
content: impl Into<Element<'a, Message, Theme, Renderer>>,
|
||||
enabled: bool,
|
||||
) -> Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: 'a,
|
||||
Theme: 'a,
|
||||
Renderer: iced::advanced::Renderer + 'a,
|
||||
{
|
||||
Element::new(Tracked {
|
||||
content: content.into(),
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
|
||||
/// 本控件自己的 `Tree` 状态:状态机 + 它那一条过渡。
|
||||
#[derive(Debug)]
|
||||
struct State {
|
||||
tracker: Tracker,
|
||||
layer: Layer,
|
||||
}
|
||||
|
||||
impl Default for State {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tracker: Tracker::new(true),
|
||||
layer: Layer::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Tracked<'a, Message, Theme, Renderer> {
|
||||
content: Element<'a, Message, Theme, Renderer>,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
|
||||
for Tracked<'_, Message, Theme, Renderer>
|
||||
where
|
||||
Renderer: renderer::Renderer,
|
||||
{
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<State>()
|
||||
}
|
||||
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(State::default())
|
||||
}
|
||||
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(&self.content)]
|
||||
}
|
||||
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> Size<Length> {
|
||||
self.content.as_widget().size_hint()
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
// 先让内容吃这条事件(按钮的点击 / 键盘都靠它),再做本控件自己的取样。
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
let over = cursor.is_over(layout.bounds());
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
if !state.tracker.event(event, over) {
|
||||
return;
|
||||
}
|
||||
// 起跑**在这里**(update 侧):同一拍 `needs_frames()` 就能看见它。
|
||||
if state.layer.retarget(state.tracker.state(), motion::scale()) {
|
||||
shell.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let state = tree.state.downcast_ref::<State>();
|
||||
let content = self.content.as_widget();
|
||||
// 状态颜色由 `style` 闭包求值,而闭包只拿得到 `&Theme` —— 所以「这一帧处于哪条过渡」
|
||||
// 只能经开画栈传给它们(见 `layer::with_current`)。
|
||||
layer::with_current(state.layer, || {
|
||||
content.draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &Renderer,
|
||||
operation: &mut dyn iced::advanced::widget::Operation,
|
||||
) {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
tree: &'a mut Tree,
|
||||
layout: Layout<'a>,
|
||||
renderer: &Renderer,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<iced::overlay::Element<'a, Message, Theme, Renderer>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! **状态层过渡**(M257 / A8):hover / pressed 的颜色不是硬切,而是按令牌时长插值。
|
||||
//!
|
||||
//! ## 这个模块解决什么
|
||||
//!
|
||||
//! M3 的状态层不是「另一种灰」,而是**内容色 + 固定 α 叠在容器上**(见 [`super`] 的文档表)。
|
||||
//! 到 M256 为止,那张表的求值是**瞬时**的:`button::Status` 一变,背景色当帧跳到 8% / 10%。
|
||||
//! 本模块给这件事加一条**时间轴**,判据是「泵被驱动了几帧 + 端点精确 + 中间态单调」。
|
||||
//!
|
||||
//! ## 两条时间轴,一个订阅点
|
||||
//!
|
||||
//! 控件状态(hover / pressed)活在各自的 iced `Tree` 里,所以过渡的 `from/to` 也放在那里
|
||||
//! (见 [`super::hover`] 的包装控件)。本模块只持一条**进程内时钟**与一张**到期表**:
|
||||
//!
|
||||
//! | 谁 | 存什么 | 何时推进 |
|
||||
//! |---|---|---|
|
||||
//! | 包装控件(`Tree` 状态) | `from` / `to` / 起跑时刻 | `Widget::update` 里改目标 |
|
||||
//! | 本模块(thread-local) | `NOW` + 到期时刻表 | `motion::advance` 每帧调 [`advance`] |
|
||||
//!
|
||||
//! [`is_animating`] 被 `motion::needs_frames()` 问(**唯一的订阅判断点**),所以状态层过渡与
|
||||
//! 页面 / 模态 / 折叠共用同一条 `window::frames()` 订阅,不另开订阅。
|
||||
//!
|
||||
//! ## 起跑必须在 update 侧(M256 的 P1-1 教训)
|
||||
//!
|
||||
//! `button` 的 hover 状态只在 `draw` 侧(`style` 闭包)可见,而 `draw` 发生在订阅表求值**之后**
|
||||
//! ⇒ 若在 draw 侧起跑,「鼠标停住不动」的那段会卡在 `progress = 0`(状态层永远看不见)。
|
||||
//! 所以目标态的**改变**全部发生在 [`super::hover`] 包装控件的 `Widget::update` 里——那里早于
|
||||
//! 订阅表求值,帧因此当拍就订阅上。
|
||||
//!
|
||||
//! ## `MotionScale::NONE` = 严格等于终值
|
||||
//!
|
||||
//! `ms = 0` ⇒ [`Layer::progress`] 恒为 `1.0`、不登记到期 ⇒ [`is_animating`] 不会被它点亮
|
||||
//! ⇒ **一帧都不订阅**。这不是「很快走完」,是「根本不存在中间态」。
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
|
||||
use iced::Color;
|
||||
|
||||
use unlucky_design::motion::{DurationToken, Easing, MotionScale};
|
||||
|
||||
use super::Interaction;
|
||||
use crate::app::components::motion::{Leg, MAX_STEP_MS};
|
||||
|
||||
/// 进(状态层变强:静息→悬停、悬停→按下):`short1` 50ms + 标准减速。
|
||||
///
|
||||
/// 出处:`14-motion-and-animation.md` §3 的 A8 行点名 `Short1`(50ms)。50ms 在 62.5Hz 的采样下
|
||||
/// 是 3 帧——数量少但**不是硬切**(3 个可判别的中间值),而且 hover 本来就该「快而轻」。
|
||||
pub const ENTER: Leg = Leg {
|
||||
duration: DurationToken::Short1,
|
||||
easing: Easing::StandardDecelerate,
|
||||
};
|
||||
|
||||
/// 出(状态层变弱:按下→悬停、悬停→静息):同档时长 + 标准加速。
|
||||
///
|
||||
/// 为什么进出同档而不是「出更短」:50ms 已经是最短档,再短就没有中间态可采样了(回到硬切)。
|
||||
/// 两条曲线的差别(减速 vs 加速)承担了「进略长于出」的观感差。
|
||||
pub const EXIT: Leg = Leg {
|
||||
duration: DurationToken::Short1,
|
||||
easing: Easing::StandardAccelerate,
|
||||
};
|
||||
|
||||
thread_local! {
|
||||
/// 进程内时钟(毫秒)。只由 [`advance`] 推。
|
||||
#[allow(clippy::missing_const_for_thread_local)]
|
||||
static NOW: Cell<f32> = const { Cell::new(0.0) };
|
||||
/// 还没到期的过渡(时刻表)。到期的**当场剔除**,否则空闲也一直要帧。
|
||||
#[allow(clippy::missing_const_for_thread_local)]
|
||||
static DEADLINES: RefCell<Vec<f32>> = const { RefCell::new(Vec::new()) };
|
||||
/// 正在画的控件栈([`with_current`] 压、包装控件的 `draw` 用)。
|
||||
#[allow(clippy::missing_const_for_thread_local)]
|
||||
static STACK: RefCell<Vec<Layer>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// 当前时刻(毫秒,进程内单调)。
|
||||
pub fn now_ms() -> f32 {
|
||||
NOW.with(Cell::get)
|
||||
}
|
||||
|
||||
/// 推进时钟(`motion::advance` 每帧调一次)。
|
||||
///
|
||||
/// 单步上限取 [`MAX_STEP_MS`](与帧泵同一条):空闲醒来 / 掉帧时 stale 的帧基准不许把一段
|
||||
/// 50ms 的过渡一次性抹完(那会让「中间态」在真机上消失,而在单测里照样绿)。
|
||||
pub fn advance(dt_ms: f64) {
|
||||
if !dt_ms.is_finite() || dt_ms <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let step = dt_ms.min(MAX_STEP_MS) as f32;
|
||||
NOW.with(|now| now.set(now.get() + step));
|
||||
let now = now_ms();
|
||||
DEADLINES.with(|list| list.borrow_mut().retain(|deadline| *deadline > now));
|
||||
}
|
||||
|
||||
/// 现在有没有状态层过渡在跑(`motion::needs_frames` 的一个分支)。
|
||||
pub fn is_animating() -> bool {
|
||||
let now = now_ms();
|
||||
DEADLINES.with(|list| {
|
||||
let mut list = list.borrow_mut();
|
||||
list.retain(|deadline| *deadline > now);
|
||||
!list.is_empty()
|
||||
})
|
||||
}
|
||||
|
||||
/// 一次状态层过渡(值放在控件的 `Tree` 状态里,**不**在这里)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Layer {
|
||||
/// 起点态(颜色从这里出发)。
|
||||
from: Interaction,
|
||||
/// 目标态(颜色走到这里)。
|
||||
to: Interaction,
|
||||
/// 起跑时刻。
|
||||
at: f32,
|
||||
/// 这一腿的时长(已按 `MotionScale` 缩放;`0` = 关着动效)。
|
||||
ms: f32,
|
||||
/// 这一腿的曲线。
|
||||
easing: Easing,
|
||||
}
|
||||
|
||||
impl Layer {
|
||||
/// 静息层(还没发生过任何过渡):进度严格 `1.0`,等价于今天的行为。
|
||||
pub const REST: Self = Self {
|
||||
from: Interaction::Enabled,
|
||||
to: Interaction::Enabled,
|
||||
at: 0.0,
|
||||
ms: 0.0,
|
||||
easing: EXIT.easing,
|
||||
};
|
||||
|
||||
/// 出厂的层(与 [`Layer::REST`] 同值;`Default` 便于 `Tree` 状态构造)。
|
||||
pub fn new() -> Self {
|
||||
Self::REST
|
||||
}
|
||||
|
||||
/// 换目标;返回 `true` = 起跑了新的一条腿(调用方该请求重绘)。
|
||||
///
|
||||
/// `scale` 是**唯一**的降级口:`NONE` 时 `ms = 0`(进度恒为终值,且不登记到期 ⇒ 零帧)。
|
||||
/// 中途反向时 `from` 取**上一个目标**(不是当前插值色):50ms 的腿上最多差一个 α 档,
|
||||
/// 肉眼不可见;换来的是「不用每帧写回状态」(写回会让过渡自我拖动)。
|
||||
pub fn retarget(&mut self, state: Interaction, scale: MotionScale) -> bool {
|
||||
if state == self.to {
|
||||
return false;
|
||||
}
|
||||
let stronger = rank(state) >= rank(self.to);
|
||||
let leg = if stronger { ENTER } else { EXIT };
|
||||
self.from = self.to;
|
||||
self.to = state;
|
||||
self.at = now_ms();
|
||||
self.ms = scale.ms(leg.duration);
|
||||
self.easing = leg.easing;
|
||||
if self.ms > 0.0 {
|
||||
// 多留一帧:到期后还至少要再画一次,屏幕上的**最后一帧才是终值**(否则停在 0.97)。
|
||||
DEADLINES.with(|list| list.borrow_mut().push(self.at + self.ms + FRAME_GRACE_MS));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// 这一帧的进度:`0` = 完全 `from`,`1` = 完全 `to`(**端点逐位精确**)。
|
||||
pub fn progress(&self) -> f32 {
|
||||
if self.ms <= 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
let t = (now_ms() - self.at) / self.ms;
|
||||
if t >= 1.0 {
|
||||
return 1.0;
|
||||
}
|
||||
if t <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
self.easing.sample(t).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Layer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 态的「强 → 弱」次序(决定用 [`ENTER`] 还是 [`EXIT`] 那条腿)。
|
||||
const fn rank(state: Interaction) -> u8 {
|
||||
match state {
|
||||
Interaction::Disabled => 0,
|
||||
Interaction::Enabled => 1,
|
||||
Interaction::Hovered => 2,
|
||||
Interaction::Focused | Interaction::Pressed => 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// 到期之后多等一帧(≈16ms):保证「最后一帧画的是终值」。
|
||||
const FRAME_GRACE_MS: f32 = 16.0;
|
||||
|
||||
/// 画一棵子树时把它的层压到栈顶(包装控件的 `draw` 里调;`draw` 结束自动弹栈)。
|
||||
pub fn with_current<R>(layer: Layer, draw: impl FnOnce() -> R) -> R {
|
||||
STACK.with(|stack| stack.borrow_mut().push(layer));
|
||||
let out = draw();
|
||||
STACK.with(|stack| {
|
||||
stack.borrow_mut().pop();
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// 当前栈顶的过渡;没有包装控件 = `None`(= 调用方按今天的行为求值)。
|
||||
pub fn current() -> Option<(Interaction, Interaction, f32)> {
|
||||
STACK.with(|stack| {
|
||||
stack
|
||||
.borrow()
|
||||
.last()
|
||||
.map(|layer| (layer.from, layer.to, layer.progress()))
|
||||
})
|
||||
}
|
||||
|
||||
/// 两个不透明色的插值:`p <= 0` 逐位等于 `from`,`p >= 1` 逐位等于 `to`。
|
||||
///
|
||||
/// **不产生 α**:两端都是不透明色(D27:GPU/CPU 两档逐像素一致),中间值也是不透明色。
|
||||
pub fn blend(from: Color, to: Color, p: f32) -> Color {
|
||||
if p <= 0.0 {
|
||||
return from;
|
||||
}
|
||||
if p >= 1.0 {
|
||||
return to;
|
||||
}
|
||||
Color {
|
||||
r: from.r + (to.r - from.r) * p,
|
||||
g: from.g + (to.g - from.g) * p,
|
||||
b: from.b + (to.b - from.b) * p,
|
||||
a: from.a + (to.a - from.a) * p,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn reset() {
|
||||
NOW.with(|now| now.set(0.0));
|
||||
DEADLINES.with(|list| list.borrow_mut().clear());
|
||||
STACK.with(|stack| stack.borrow_mut().clear());
|
||||
}
|
||||
|
||||
/// 测试专用:按给定毫秒推进(`advance` 读 dt,单测不能等真实时间)。
|
||||
#[cfg(test)]
|
||||
pub fn advance_by(ms: f64) {
|
||||
advance(ms);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,145 @@
|
||||
//! 状态层过渡的判据(M257 / A8):**帧数 + 端点精确 + 中间态严格单调**,外加两条硬契约。
|
||||
//!
|
||||
//! 两条硬契约(塔在 M257 briefing 里点名的):
|
||||
//!
|
||||
//! 1. `MotionScale::NONE` 下进度**严格**等于终值(独立用例,不靠别的用例顺带覆盖);
|
||||
//! 2. 「鼠标停住不动」的那段时间里过渡必须**自己走完并停在终值**——本文件用「只推时钟、不喂事件」
|
||||
//! 的形态抓「停在 progress 0」这个具体故障。
|
||||
|
||||
use super::*;
|
||||
use crate::app::components::motion::FRAME_MS;
|
||||
use unlucky_design::motion::MotionScale;
|
||||
|
||||
const STANDARD: MotionScale = MotionScale::STANDARD;
|
||||
const NONE: MotionScale = MotionScale::NONE;
|
||||
|
||||
/// 一条全新的过渡(每个用例开头都从干净时钟起跑)。
|
||||
fn fresh() -> Layer {
|
||||
reset();
|
||||
Layer::new()
|
||||
}
|
||||
|
||||
/// **悬停:50ms 内走到 1.0,端点精确,中间严格单调——且**不靠任何事件**。**
|
||||
///
|
||||
/// 这条用例直接对着塔的硬判据写:*「鼠标停住不动的整段时间里,过渡必须走完并停在终值」*。
|
||||
/// 实现里唯一驱动它的是 [`advance`](`motion::advance` 每帧调),所以「停在 progress 0」
|
||||
/// (= 起跑写在 draw 侧、没有帧订阅)在这里会立刻红。
|
||||
///
|
||||
/// 抓得住:①把 `short1`(50) 改成 0(进度当帧就是 1.0 ⇒ 「第一帧必须是 0」的断言红);
|
||||
/// ②把 `advance` 的时钟加去掉(进度永远 0 ⇒ 终值断言红);③把终止条件删掉(`is_animating` 恒真)。
|
||||
/// 抓不住:真实帧率(那由 `window::frames()` 决定)、hover 的观感。
|
||||
#[test]
|
||||
fn hovering_fades_in_over_the_token_duration_without_any_further_events() {
|
||||
let mut layer = fresh();
|
||||
assert_eq!(layer.progress(), 1.0, "静息层:进度是终值(= 今天的行为)");
|
||||
|
||||
assert!(
|
||||
layer.retarget(Interaction::Hovered, STANDARD),
|
||||
"目标变了必须起跑"
|
||||
);
|
||||
assert_eq!(layer.progress(), 0.0, "第一帧必须还在静息端(端点精确)");
|
||||
assert!(is_animating(), "起跑之后同一拍就该要帧");
|
||||
|
||||
let mut values = vec![layer.progress()];
|
||||
let mut frames = 0;
|
||||
while is_animating() {
|
||||
advance_by(FRAME_MS);
|
||||
values.push(layer.progress());
|
||||
frames += 1;
|
||||
assert!(frames < 60, "过渡没停,疑似终止条件失效");
|
||||
}
|
||||
|
||||
// 50ms / 16ms ≈ 3~4 帧(含到期后多要的那一帧)。
|
||||
assert!(
|
||||
(3..=5).contains(&frames),
|
||||
"50ms 应落在 3~5 帧,实测 {frames} 帧"
|
||||
);
|
||||
assert_eq!(values.last().copied(), Some(1.0), "终值必须**严格**是 1.0");
|
||||
for pair in values.windows(2) {
|
||||
assert!(
|
||||
pair[1] >= pair[0],
|
||||
"淡入必须单调不减:{:?} → {:?}",
|
||||
pair[0],
|
||||
pair[1]
|
||||
);
|
||||
}
|
||||
let distinct: Vec<_> = values.iter().filter(|v| **v > 0.0 && **v < 1.0).collect();
|
||||
assert!(
|
||||
!distinct.is_empty(),
|
||||
"必须有可判别的中间态(否则就是硬切):{values:?}"
|
||||
);
|
||||
assert!(!is_animating(), "走完必须退订帧");
|
||||
assert_eq!(layer.progress(), 1.0, "没有轨道时进度恒为终值");
|
||||
}
|
||||
|
||||
/// **`MotionScale::NONE` = 严格等于终值**(独立用例;不靠上一条顺带覆盖)。
|
||||
///
|
||||
/// 抓得住:把 `retarget` 里的 `NONE ⇒ ms = 0` 删掉(那会跑一遍 50ms ⇒ 首帧进度是 0.0,本用例红);
|
||||
/// 或把 NONE 实现成「很快走完」(同样红:本用例断言的是**当帧就到位**,不是「几帧后到位」)。
|
||||
/// 抓不住:`NONE` 之外的档位(那是别的用例的事)。
|
||||
#[test]
|
||||
fn motion_scale_none_has_no_intermediate_state_at_all() {
|
||||
let mut layer = fresh();
|
||||
assert!(layer.retarget(Interaction::Hovered, NONE));
|
||||
assert_eq!(layer.progress(), 1.0, "NONE 下当帧就是终值(严格等于 1)");
|
||||
assert!(!is_animating(), "NONE 下不许要帧(不是「很快走完」)");
|
||||
|
||||
assert!(layer.retarget(Interaction::Enabled, NONE));
|
||||
assert_eq!(layer.progress(), 1.0, "移出同样是当帧终值");
|
||||
assert!(!is_animating());
|
||||
}
|
||||
|
||||
/// 同态重复不改目标(否则每次 draw 都会重启一条腿 ⇒ 过渡被无限拉长)。
|
||||
///
|
||||
/// 抓得住:把 `retarget` 的 `state == self.to` 早退删掉。
|
||||
#[test]
|
||||
fn retargeting_the_same_state_is_a_no_op() {
|
||||
let mut layer = fresh();
|
||||
assert!(layer.retarget(Interaction::Hovered, STANDARD));
|
||||
advance_by(FRAME_MS);
|
||||
let mid = layer.progress();
|
||||
assert!(!layer.retarget(Interaction::Hovered, STANDARD));
|
||||
assert_eq!(layer.progress(), mid, "同一目标的重复报告不许重置进度");
|
||||
}
|
||||
|
||||
/// 按下比悬停更「强」:从悬停转按下走 `ENTER` 那条腿(起跑端是**上一个目标**,不是静息)。
|
||||
///
|
||||
/// 抓得住:把 `retarget` 里的 `self.from = self.to` 写成 `self.from = Interaction::Enabled`
|
||||
/// (按下瞬间会先掉回静息色一帧——状态层闪一下),本用例断言 `from` 是 `Hovered`。
|
||||
#[test]
|
||||
fn pressing_starts_from_the_previous_target_not_from_rest() {
|
||||
let mut layer = fresh();
|
||||
layer.retarget(Interaction::Hovered, STANDARD);
|
||||
// 不推进:模拟「悬停刚开始就按下」(鼠标点得比淡入还快)。
|
||||
assert!(layer.retarget(Interaction::Pressed, STANDARD));
|
||||
let (from, to, p) = current_from(&layer);
|
||||
assert_eq!(from, Interaction::Hovered, "起跑端必须是上一个目标");
|
||||
assert_eq!(to, Interaction::Pressed);
|
||||
assert_eq!(p, 0.0, "新的一条腿从 0 开始");
|
||||
}
|
||||
|
||||
/// 把层的两端读出来(`Layer` 的字段是私有的,这里经 [`with_current`] 走公开路径)。
|
||||
fn current_from(layer: &Layer) -> (Interaction, Interaction, f32) {
|
||||
with_current(*layer, || current().expect("刚压进去的层必须在栈顶"))
|
||||
}
|
||||
|
||||
/// 两个不透明色的插值:**端点逐位相等**,中点严格落在两者之间。
|
||||
///
|
||||
/// 抓得住:①把端点写成「差不多」(`p == 0` 时返回插值结果 0.999…)——逐位断言红;
|
||||
/// ②引入 α(`a` 不再是 1.0)——D27 的「两个后端逐像素一致」就靠这条守着。
|
||||
#[test]
|
||||
fn blend_hits_both_endpoints_bit_for_bit() {
|
||||
let from = iced::Color::from_rgb(0.1, 0.2, 0.3);
|
||||
let to = iced::Color::from_rgb(0.7, 0.8, 0.9);
|
||||
|
||||
assert_eq!(blend(from, to, 0.0), from);
|
||||
assert_eq!(blend(from, to, 1.0), to);
|
||||
|
||||
for p in [0.25, 0.5, 0.75] {
|
||||
let mid = blend(from, to, p);
|
||||
assert!((0.1..=0.7).contains(&mid.r), "r 必须落在两端之间");
|
||||
assert!((0.2..=0.8).contains(&mid.g));
|
||||
assert!((0.3..=0.9).contains(&mid.b));
|
||||
assert_eq!(mid.a, 1.0, "中间态也不许引入半透明(D27)");
|
||||
}
|
||||
}
|
||||
@@ -64,11 +64,14 @@ use unlucky_design::color::{Role, StateLayer};
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
mod hover;
|
||||
mod tracker;
|
||||
|
||||
// 本 mission 没有自绘控件,所以这条再导出在 crate 内暂时无人使用;
|
||||
// 它是给四条家族线(Slider / Switch 轨道要自绘)的公共入口,路径与拆分前一致。
|
||||
#[allow(unused_imports)]
|
||||
/// 状态层**过渡的时间轴**(M257 / A8):hover / pressed 按令牌时长插值,不再是硬切。
|
||||
pub mod layer;
|
||||
|
||||
// 事件 → 态的**状态机**在 [`tracker`]:M257 起它有真消费者——[`hover`] 的取样控件。
|
||||
pub use hover::tracked;
|
||||
pub use tracker::Tracker;
|
||||
|
||||
// 本模块会被四条家族线各扩一次:加东西前先看行数余额(单文件 ≤400,AGENTS.md §3-1)。
|
||||
@@ -151,6 +154,40 @@ pub fn overlay_color(ui: &Ui, state: Interaction, content: Role, container: Role
|
||||
.map(|layer| ui.state_over(layer, content, container))
|
||||
}
|
||||
|
||||
/// 这一帧的状态层颜色(**组件唯一入口**,A8 起带时间轴)。
|
||||
///
|
||||
/// 三种情形,前两种都**逐位等于** M257 之前的输出:
|
||||
///
|
||||
/// | 情形 | 返回 | 为什么 |
|
||||
/// |---|---|---|
|
||||
/// | 没有取样控件(gallery 强制态 / 未包装路径) | [`overlay_color`] | 取证图与旧基线一字不差 |
|
||||
/// | 有取样控件且过渡已到位(`p >= 1`) | 目标态的颜色 | 静息与终态都是**精确**的令牌求值 |
|
||||
/// | 过渡中(`0 < p < 1`) | 两态不透明色的插值 | 中间帧既不是起点也不是终点(可判别的中间态) |
|
||||
///
|
||||
/// 中间态为什么能插值而不产生半透明:两端都是 `state_over` 合成的**不透明**色,线性插值的结果
|
||||
/// 也不透明(D27:GPU/CPU 两档逐像素一致)。`back` 是状态层合成的底(填充按钮 = 容器角色,
|
||||
/// 透明容器 = [`Base`]),当前态不叠层时以它为端点色(那正是「没画状态层」时看到的颜色)。
|
||||
pub fn layer_color(ui: &Ui, state: Interaction, content: Role, back: Role) -> Option<Color> {
|
||||
let Some((from, to, p)) = layer::current() else {
|
||||
return overlay_color(ui, state, content, back);
|
||||
};
|
||||
let source = overlay_color(ui, from, content, back);
|
||||
let target = overlay_color(ui, to, content, back);
|
||||
if p >= 1.0 {
|
||||
return target;
|
||||
}
|
||||
if p <= 0.0 {
|
||||
return source;
|
||||
}
|
||||
// 端点缺失(静息态不叠层)时用**底**补一个不透明色:那正是用户眼里的「没有状态层」。
|
||||
let backdrop = ui.color(back);
|
||||
Some(layer::blend(
|
||||
source.unwrap_or(backdrop),
|
||||
target.unwrap_or(backdrop),
|
||||
p,
|
||||
))
|
||||
}
|
||||
|
||||
mod base;
|
||||
mod disabled;
|
||||
|
||||
@@ -235,6 +272,68 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// **没有取样控件时,[`layer_color`] 逐位等于 M257 之前的 [`overlay_color`]**。
|
||||
///
|
||||
/// 抓得住:有人把时间轴接进求值路径时顺手改了静息/终态的颜色(D30 的旧基线会漂)。
|
||||
/// 抓不住:过渡中间帧(那是下一条用例与 `layer::tests` 的事)。
|
||||
#[test]
|
||||
fn layer_color_without_a_sampler_is_bit_identical_to_overlay_color() {
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let ui = ui(flavor);
|
||||
for state in Interaction::ALL {
|
||||
for (content, back) in [
|
||||
(Role::OnPrimary, Role::Primary),
|
||||
(Role::OnSurface, Role::Surface),
|
||||
(Role::OnSurfaceVariant, Role::SurfaceContainerLow),
|
||||
] {
|
||||
assert_eq!(
|
||||
layer_color(&ui, state, content, back),
|
||||
overlay_color(&ui, state, content, back),
|
||||
"{flavor:?}/{state:?}/{content:?} 必须逐位退回旧口径"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **过渡中间帧:颜色落在两端之间,且不透明**(D27:不许把 α 交给渲染器)。
|
||||
///
|
||||
/// 抓得住:①中间帧被写成硬切(`p` 被忽略 ⇒ 起点色或终点色,`assert_ne` 红);
|
||||
/// ②有人为了「淡出」改用 `Color { a: 0.5 }`(末尾的「必须不透明」红)。
|
||||
/// 抓不住:观感(8%/10% 的差在图上只有几级色阶,好不好看得看图)。
|
||||
#[test]
|
||||
fn a_running_transition_yields_an_opaque_colour_between_the_two_ends() {
|
||||
use super::layer;
|
||||
|
||||
let ui = ui(SchemeFlavor::Light);
|
||||
layer::reset();
|
||||
let resting = overlay_color(&ui, Interaction::Enabled, Role::OnSurface, Role::Surface);
|
||||
let hovered = overlay_color(&ui, Interaction::Hovered, Role::OnSurface, Role::Surface)
|
||||
.expect("hover 有颜色");
|
||||
|
||||
let mut state = layer::Layer::new();
|
||||
state.retarget(
|
||||
Interaction::Hovered,
|
||||
unlucky_design::motion::MotionScale::STANDARD,
|
||||
);
|
||||
layer::advance_by(16.0);
|
||||
|
||||
let mid = layer::with_current(state, || {
|
||||
layer_color(&ui, Interaction::Hovered, Role::OnSurface, Role::Surface)
|
||||
})
|
||||
.expect("过渡中必须有颜色");
|
||||
|
||||
assert_eq!(mid.a, 1.0, "中间帧也必须不透明(D27)");
|
||||
assert!(
|
||||
resting.is_none(),
|
||||
"前提:静息态本来不叠层(透明容器的情形)"
|
||||
);
|
||||
assert_ne!(mid, hovered, "中间帧不许等于终点色(那就是硬切)");
|
||||
let backdrop = ui.color(Role::Surface);
|
||||
assert_ne!(mid, backdrop, "中间帧也不许还停在起点色的底上");
|
||||
layer::reset();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_is_weaker_than_pressed_on_the_same_container() {
|
||||
let ui = ui(SchemeFlavor::Light);
|
||||
|
||||
Reference in new issue
Block a user