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 未改)
146 lines
6.5 KiB
Rust
146 lines
6.5 KiB
Rust
//! 状态层过渡的判据(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)");
|
||
}
|
||
}
|