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:
AskaEth committed 2026-11-12 20:05:58 +08:00
1 parent c4706c759c
commit b61f342431
22 files changed
+1837 -145

No files matched your search

@@ -0,0 +1,174 @@
//! 右栗折叠轨道(M257 / A2)的判据:泵帧数 + 端点精确 + 中间态严格单调。
//!
//! 判据口径同 M256(`14-motion-and-animation.md` §7-7):**不靠像素比对**。像素只能证明「某一张图
//! 里宽度是多少」,证明不了「中间有 12 个相位」与「结束时不多要一帧」。
use super::{settle, standard};
use crate::app::components::motion::*;
use unlucky_design::motion::MotionScale;
/// 一路采样折叠进度(含终点),用完自己 settle。
fn samples(pump: &mut Pump) -> Vec<f32> {
let mut out = vec![pump.rail_progress()];
let mut guard = 0;
while pump.is_animating() {
pump.advance(FRAME_MS);
out.push(pump.rail_progress());
guard += 1;
assert!(guard < 200, "折叠轨道没在跑完,疑似终止条件失效");
}
out
}
/// **展开:帧数落在 12~20 之间、端点精确、中间严格单调**。
///
/// 抓得住:①有人把 `medium1`(250) 改成 600ms(帧数窜到 38)或改成 0(硬跳,帧数 0);
/// ②终止条件被删掉(`while is_animating` 直接跑满 200 帧的兜底断言红);③插值写成阶跃
/// (单调性断言里的 `>` 会变成 `==`)。
/// 抓不住:观感是否「像 M3E」——那要出图(D30),本用例只保证相位数量与端点。
#[test]
fn expanding_runs_the_expected_number_of_frames_and_lands_exactly_on_one() {
let mut pump = standard();
assert!(pump.observe_rail(true), "目标变了必须起跑");
assert_eq!(pump.rail_progress(), 0.0, "第一帧还在折叠档(端点精确)");
let values = samples(&mut pump);
let frames = values.len() - 1;
assert!(
(12..=20).contains(&frames),
"250ms / 16ms 应落在 12~20 帧,实测 {frames} 帧"
);
assert_eq!(
values.last().copied(),
Some(1.0),
"结束时必须**严格**是 1.0(不是 0.99x)"
);
for pair in values.windows(2) {
assert!(
pair[1] > pair[0],
"展开必须严格单调:{:?} → {:?}",
pair[0],
pair[1]
);
}
assert!(!pump.is_animating(), "跑完必须清空轨道(否则空闲烧帧)");
assert_eq!(pump.rail_progress(), 1.0, "没有轨道时是终值");
}
/// **收起:同一条判据,反向**(`short4` 200ms ⇒ 12~13 帧)。
#[test]
fn collapsing_is_strictly_monotone_downwards_and_stops_at_zero() {
let mut pump = standard();
pump.observe_rail(true);
settle(&mut pump);
assert_eq!(pump.rail_progress(), 1.0);
assert!(pump.observe_rail(false));
let values = samples(&mut pump);
let frames = values.len() - 1;
assert!(
(10..=20).contains(&frames),
"200ms / 16ms 应落在 10~20 帧,实测 {frames} 帧"
);
assert_eq!(values.last().copied(), Some(0.0), "结束时必须严格是 0.0");
for pair in values.windows(2) {
assert!(pair[1] < pair[0], "收起必须严格单调下降");
}
assert!(!pump.is_animating());
}
/// **`MotionScale::NONE`:严格等于终值,且一帧都不要**(独立的用例,不靠别的用例顺带覆盖)。
///
/// 抓得住:把 `observe_rail` 里的 `is_off()` 分支删掉(那会插一条 0 时长轨道 ⇒ 进度仍是 1.0,
/// 但 `is_animating()` 会有一拍为真 ⇒ 本用例的「一帧都不要」断言红);或把 `is_off()`
/// 写成「跑一遍 0 时长」(同样红)。
/// 抓不住:中间帧**该不该**存在(NONE 下不该有中间帧,这是定义,不是观感)。
#[test]
fn motion_scale_none_puts_the_rail_strictly_at_the_end_value() {
let mut pump = Pump::new();
pump.configure(MotionScale::NONE, None);
assert!(
!pump.observe_rail(true),
"NONE 下不许起跑轨道(不是什么都没发生,是**不许有中间态**)"
);
assert_eq!(pump.rail_progress(), 1.0, "展开档的终值必须逐位是 1.0");
assert!(!pump.is_animating(), "NONE 下一帧都不许要");
assert!(!pump.observe_rail(false));
assert_eq!(pump.rail_progress(), 0.0, "折叠档的终值必须逐位是 0.0");
assert!(!pump.is_animating());
}
/// **中途反向不跳变**:折叠到一半改主意展开,下一帧的进度必须从当前值继续(不是回 0/1)。
///
/// 抓得住:把 `observe_rail` 里的 `from` 写成常量 0 / 1(那会让宽度在半路跳一下)。
/// 抓不住:真实手感(那个「拐点」顺不顺)——只保证数值连续。
#[test]
fn reversing_mid_flight_continues_from_the_current_progress() {
let mut pump = standard();
pump.observe_rail(true);
for _ in 0..4 {
pump.advance(FRAME_MS);
}
let before = pump.rail_progress();
assert!(
before > 0.1 && before < 0.9,
"前提:应该处在中间(实测 {before})"
);
pump.observe_rail(false);
let after = pump.rail_progress();
assert!(
(after - before).abs() < 1e-6,
"反向那一帧不许跳变:{before} → {after}"
);
}
/// **拆出档不走过渡**:`snap_rail` 直接落终值,且把在跑的轨道掐掉。
///
/// 抓得住:有人把 `Detached` 也接进这条轨道(那会做一次「48 → 0」的缩水动画)。
/// 抓不住:拆分本身的观感(那是布局切换,不在动效范围里)。
#[test]
fn detaching_snaps_instead_of_animating() {
let mut pump = standard();
pump.observe_rail(true);
pump.advance(FRAME_MS);
assert!(pump.is_animating());
assert!(pump.snap_rail(false), "有轨道在跑时落档要报「掐掉了」");
assert_eq!(pump.rail_progress(), 0.0);
assert!(!pump.is_animating());
}
/// **帧订阅的委托**:状态层过渡在跑时 `motion::needs_frames()` 必须为真。
///
/// 这是 `app/subscription.rs` 唯一的判断点(`motion::needs_frames()`):A8 的时钟若没被委托进
/// 这里,产品里就一帧都不会来(状态层卡在起始值),而单元测试照样能靠 `advance_by` 摆出绿。
///
/// 抓得住:删掉 `motion::needs_frames` 里对 `interaction::layer::is_animating()` 的那一句。
/// 抓不住:真实窗口的帧率(那由 `window::frames()` 决定,见 `app/subscription.rs` 的表)。
#[test]
fn the_state_layer_clock_feeds_the_single_subscription_point() {
use crate::app::components::interaction::layer;
reset();
assert!(!needs_frames(), "前提:静息时不要帧");
let mut state = layer::Layer::new();
state.retarget(
crate::app::components::interaction::Interaction::Hovered,
MotionScale::STANDARD,
);
assert!(
needs_frames(),
"悬停起跑之后**同一拍**就要帧(否则 draw 侧起跑的 0 进度会一直画不出来)"
);
// 推进到 50ms 之后:必须自己退订(不是「一直要帧」)。
for _ in 0..6 {
layer::advance_by(FRAME_MS);
}
assert!(!needs_frames(), "过渡走完必须立刻不要帧");
layer::reset();
}