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,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)");
}
}