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,237 @@
|
||||
//! 右栗折叠过渡的**几何判据**(M257 / A2):帧数 + 端点精确 + 严格单调 + 不溢出 + 键盘可达。
|
||||
//!
|
||||
//! 判据口径(`14-motion-and-animation.md` §7-7):**不靠像素比对**。像素能证明「某一帧的宽度是 92」,
|
||||
//! 证明不了「中间有 13 个相位」「收尾那一帧不多要帧」。观感(好不好看)走 D30 出图。
|
||||
|
||||
use unlucky_design::motion::MotionScale;
|
||||
|
||||
use super::super::metrics::{gutter_at, COLLAPSED_WIDTH, DEFAULT_EXPANDED_WIDTH};
|
||||
use super::super::state::{AgentUi, Focus, RailState};
|
||||
use crate::app::components::motion;
|
||||
|
||||
/// 取证窗口宽(与 D30 的产品默认一致)。
|
||||
const WINDOW: f32 = 1180.0;
|
||||
/// 帧泵的单帧步进(`motion::FRAME_MS` 的测试口径)。
|
||||
const FRAME: f64 = 16.0;
|
||||
|
||||
/// 每个用例都从干净的动效全局态起跑(thread-local,互不影响,但同一用例内要确定)。
|
||||
fn configure(scale: MotionScale) {
|
||||
motion::reset();
|
||||
motion::configure_for_test(scale, None);
|
||||
}
|
||||
|
||||
/// 把一整个折叠 / 展开过程采样到底(返回**含起点与终点**的宽度序列)。
|
||||
fn sample_widths(agent: &AgentUi) -> Vec<f32> {
|
||||
let mut out = vec![agent.rail_width(WINDOW)];
|
||||
let mut frames = 0;
|
||||
while motion::needs_frames() {
|
||||
motion::advance_by(FRAME);
|
||||
out.push(agent.rail_width(WINDOW));
|
||||
frames += 1;
|
||||
assert!(frames < 60, "折叠轨道没停,疑似终止条件失效");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// **展开:第一帧还是 48、中间严格单调、结束时严格等于 320、帧数落在 12~20。**
|
||||
///
|
||||
/// 抓得住:①把宽度改回硬跳(`rail_width` 直接返回静息值 ⇒ 第一帧就是 320,本用例的第一步红);
|
||||
/// ②把终止条件删掉(`while` 后面的兜底断言红);③把时长改成 600ms(帧数超 20)。
|
||||
/// 抓不住:中间帧「看起来」顺不顺(那是曲线与观感,走 D30 出图),也抓不住真实帧率。
|
||||
#[test]
|
||||
fn expanding_is_monotone_and_lands_exactly_on_the_settled_width() {
|
||||
configure(MotionScale::STANDARD);
|
||||
let mut agent = AgentUi::new();
|
||||
assert_eq!(agent.rail, RailState::Collapsed);
|
||||
|
||||
agent.expand();
|
||||
let widths = sample_widths(&agent);
|
||||
let frames = widths.len() - 1;
|
||||
|
||||
assert_eq!(
|
||||
widths.first().copied(),
|
||||
Some(COLLAPSED_WIDTH),
|
||||
"第一帧必须还在折叠档(48,端点精确)"
|
||||
);
|
||||
assert_eq!(
|
||||
widths.last().copied(),
|
||||
Some(DEFAULT_EXPANDED_WIDTH),
|
||||
"最后一帧必须是**严格** 320(不是 319.9x)"
|
||||
);
|
||||
assert!(
|
||||
(12..=20).contains(&frames),
|
||||
"250ms / 16ms 应落在 12~20 帧,实测 {frames} 帧"
|
||||
);
|
||||
for pair in widths.windows(2) {
|
||||
assert!(
|
||||
pair[1] >= pair[0],
|
||||
"宽度必须单调不减(不许来回抖):{:?} → {:?}",
|
||||
pair[0],
|
||||
pair[1]
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
widths.iter().filter(|w| **w > 48.5 && **w < 319.5).count() >= 8,
|
||||
"必须有足够多的中间宽度(否则就是硬跳):{widths:?}"
|
||||
);
|
||||
assert!(!motion::needs_frames(), "走完必须退订帧");
|
||||
motion::reset();
|
||||
}
|
||||
|
||||
/// **收起:同一条判据,反向**(`short4` 200ms ⇒ 10~13 帧)。
|
||||
#[test]
|
||||
fn collapsing_is_monotone_downwards_and_ends_exactly_on_the_strip_width() {
|
||||
configure(MotionScale::STANDARD);
|
||||
let mut agent = AgentUi::new();
|
||||
agent.expand();
|
||||
sample_widths(&agent);
|
||||
assert_eq!(agent.rail_width(WINDOW), DEFAULT_EXPANDED_WIDTH);
|
||||
|
||||
agent.collapse();
|
||||
let widths = sample_widths(&agent);
|
||||
let frames = widths.len() - 1;
|
||||
|
||||
assert_eq!(widths.first().copied(), Some(DEFAULT_EXPANDED_WIDTH));
|
||||
assert_eq!(widths.last().copied(), Some(COLLAPSED_WIDTH));
|
||||
assert!(
|
||||
(10..=20).contains(&frames),
|
||||
"200ms / 16ms 应落在 10~20 帧,实测 {frames} 帧"
|
||||
);
|
||||
for pair in widths.windows(2) {
|
||||
assert!(pair[1] <= pair[0], "收起必须单调不增:{pair:?}");
|
||||
}
|
||||
motion::reset();
|
||||
}
|
||||
|
||||
/// **`MotionScale::NONE`:右栗宽度当帧就是终值**(独立用例,包含在「右栗」这个面上)。
|
||||
///
|
||||
/// 抓得住:把 `observe_rail` 的 `is_off()` 分支删掉 ⇒ 会插一条 0 时长轨道,本用例的
|
||||
/// 「`needs_frames()` 为假」与「第一帧就是 320」两条会同时红。
|
||||
/// 抓不住:`NONE` 以外的档位。
|
||||
#[test]
|
||||
fn motion_scale_none_puts_the_rail_at_the_end_value_on_the_first_frame() {
|
||||
configure(MotionScale::NONE);
|
||||
let mut agent = AgentUi::new();
|
||||
|
||||
agent.expand();
|
||||
assert_eq!(
|
||||
agent.rail_width(WINDOW),
|
||||
DEFAULT_EXPANDED_WIDTH,
|
||||
"NONE 下展开档当帧就是 320(不是「很快走完」)"
|
||||
);
|
||||
assert!(!motion::needs_frames(), "NONE 下一帧都不许要");
|
||||
assert_eq!(
|
||||
agent.content_width(WINDOW),
|
||||
WINDOW - DEFAULT_EXPANDED_WIDTH - gutter_at(DEFAULT_EXPANDED_WIDTH),
|
||||
"中栏也必须当帧拿到终值"
|
||||
);
|
||||
|
||||
agent.collapse();
|
||||
assert_eq!(agent.rail_width(WINDOW), COLLAPSED_WIDTH);
|
||||
assert!(!motion::needs_frames());
|
||||
motion::reset();
|
||||
}
|
||||
|
||||
/// **拆出档:宽度 0、不走过场**(拆分是布局切换,不是过渡对象)。
|
||||
///
|
||||
/// 抓得住:有人把 `Detached` 也接进折叠轨道(那会做一次 48→0 的缩水,`needs_frames()` 为真)。
|
||||
#[test]
|
||||
fn detached_is_never_animated() {
|
||||
configure(MotionScale::STANDARD);
|
||||
let mut agent = AgentUi::new();
|
||||
agent.expand();
|
||||
sample_widths(&agent);
|
||||
|
||||
agent.detach();
|
||||
assert_eq!(agent.rail_width(WINDOW), 0.0, "拆出档不占宽");
|
||||
assert!(!motion::needs_frames(), "拆出不许起过渡");
|
||||
assert_eq!(agent.content_width(WINDOW), WINDOW, "中栗拿回整窗");
|
||||
motion::reset();
|
||||
}
|
||||
|
||||
/// **不溢出 + 不抖**:折叠全程的「右栗总占宽 = 面板 + 拖拽缝」单调、且不超过静息展开档。
|
||||
///
|
||||
/// 抓得住:①把 `gutter_at` 写成常量 8(收尾那一帧总宽会是 56 而不是 48 ⇒ 最后一步的严格相等红);
|
||||
/// ②让宽度插值越过目标(回弹)——单调与上限两条同时红。
|
||||
/// 抓不住:`row!` 里的兄弟顺序写错(那要出图看)。
|
||||
#[test]
|
||||
fn the_rail_never_wider_than_its_settled_footprint() {
|
||||
configure(MotionScale::STANDARD);
|
||||
let mut agent = AgentUi::new();
|
||||
agent.expand();
|
||||
|
||||
let mut totals = Vec::new();
|
||||
loop {
|
||||
let width = agent.rail_width(WINDOW);
|
||||
totals.push(width + gutter_at(width));
|
||||
if !motion::needs_frames() {
|
||||
break;
|
||||
}
|
||||
motion::advance_by(FRAME);
|
||||
}
|
||||
|
||||
let settled = DEFAULT_EXPANDED_WIDTH + gutter_at(DEFAULT_EXPANDED_WIDTH);
|
||||
for total in &totals {
|
||||
assert!(*total <= settled, "右栗总占宽越界:{total} > {settled}");
|
||||
}
|
||||
assert_eq!(totals.last().copied(), Some(settled), "收尾必须是静息总宽");
|
||||
assert_eq!(
|
||||
totals.first().copied(),
|
||||
Some(COLLAPSED_WIDTH),
|
||||
"起点是折叠档的 48(图标条没有拖拽缝)"
|
||||
);
|
||||
for pair in totals.windows(2) {
|
||||
assert!(pair[1] >= pair[0], "总占宽必须单调:{pair:?}");
|
||||
}
|
||||
motion::reset();
|
||||
}
|
||||
|
||||
/// **键盘可达性:过渡中途按 Tab 的落点与静息时完全相同**(键盘用户不许被动效拖慢)。
|
||||
///
|
||||
/// 抓得住:有人把焦点环也接进「进度」(例如按当前宽度决定环上有哪些控件)——那会让过渡中途的
|
||||
/// 落点与静息不同,本用例红。也抓住「环空了 ⇒ 焦点掉到根」:环首尾相接,从任何一个格位都能走到
|
||||
/// 一个真实控件。
|
||||
/// 抓不住:Tab 的**时序**(真窗口里按 Tab 到焦点环被画出之间隔几帧,只能人试)。
|
||||
#[test]
|
||||
fn tab_lands_on_the_same_control_mid_transition() {
|
||||
configure(MotionScale::STANDARD);
|
||||
let mut agent = AgentUi::new();
|
||||
agent.expand();
|
||||
|
||||
// 走到中途(宽度既不是 48 也不是 320)。
|
||||
for _ in 0..6 {
|
||||
motion::advance_by(FRAME);
|
||||
}
|
||||
let width = agent.rail_width(WINDOW);
|
||||
assert!(
|
||||
width > COLLAPSED_WIDTH && width < DEFAULT_EXPANDED_WIDTH,
|
||||
"前提:应该处在过渡中途(实测 {width})"
|
||||
);
|
||||
|
||||
// 目标档是展开:环 = [Collapse, Detach, Send, Composer](与宽度无关)。
|
||||
let mid = Focus::Collapse.step(false, RailState::Expanded, false, false);
|
||||
assert_eq!(mid, Focus::Detach, "中途 Tab 的落点必须是真实控件");
|
||||
|
||||
// 收起到目标档是折叠:环 = [Collapse, Send, Composer],且首尾相接(不会掉到根)。
|
||||
let collapsed = Focus::Composer.step(false, RailState::Collapsed, false, false);
|
||||
assert_eq!(
|
||||
collapsed,
|
||||
Focus::Collapse,
|
||||
"折叠档的环必须首尾相接(焦点不许掉到根)"
|
||||
);
|
||||
motion::reset();
|
||||
}
|
||||
|
||||
/// 采样序列里至少要有 8 个「既不是起点也不是终点」的宽度——判定「不是硬切」的真正依据。
|
||||
///
|
||||
/// 单独一条是因为它比帧数更直接:帧数只说明时间被走完,中间宽度才说明**有中间态**。
|
||||
#[test]
|
||||
fn there_are_real_intermediate_widths() {
|
||||
configure(MotionScale::STANDARD);
|
||||
let mut agent = AgentUi::new();
|
||||
agent.expand();
|
||||
let widths = sample_widths(&agent);
|
||||
let middle = widths.iter().filter(|w| **w > 48.5 && **w < 319.5).count();
|
||||
assert!(middle >= 8, "中间宽度只有 {middle} 个:{widths:?}");
|
||||
motion::reset();
|
||||
}
|
||||
Reference in new issue
Block a user