feat(m192): 动效帧泵——design motion 令牌接进 iced 的有界帧订阅 + 首批 3 条入场动效
令牌早就在 `crates/design/src/motion/` 里齐着(16 档时长 / 可采样缓动 / 弹簧二维表 / MotionScale 降级),但适配层从没消费过,所以一切状态变化都是硬切。本次补的正是那条中间层: 新增 `components/motion/`(时间轴,不是控件家族) - `pump.rs`:`Slot`(boot/screen/detail 三个互斥触发点)× `Spec`(时长档 × 缓动档, **唯一一张表**,页面拿不到「给我 250ms」这种口子,D31)→ 进度采样;时间轴按 16ms 结算 (≈60 次/秒),到期轨道**当场删除**。 - `mod.rs`:进程内单时间轴(thread-local;iced 在事件循环线程上串行调 update/view, 与 `App::update(&mut self)` 同一条纪律,测试也因此天然隔离)+ `enter`/`shifted` 两个页面入口。 - **默认 NONE**:只有产品 `on_boot` 按档位打开;`pages_demo` 这类截图 harness 从不打开。 接线(都接在既有那条链上,不另开循环) - `App::needs_frames` 多一条「有轨道」分支 → 与截图 / Busy / toast / watch_until 共享**同一个布尔**, `window::frames()` 在源码里仍只有一处(守卫:双订阅就红)。 - `on_frame` 每帧推进时间轴;`on_heartbeat` 不变(有帧时不推进)。 - 定档策略集中在 `update/boot_env.rs::motion_scale`:截图档 NONE(D30 出图可复现)、 CPU 渲染档 HALF、GPU 档 STANDARD,`ULL_MOTION` 可强制。 首批 3 条动效(`pages::render` 一个漏斗,静息恒为 (0,0) ⇒ 既有截图基线一张都不用重出) - 首屏进入 16px / 换页 12px(纵向) / 列表↔详情 8px(横向); 时长 400/300/350ms 全部取自 `DurationToken`,曲线 `Easing::Standard` (`EmphasizedDecelerate` 把 75% 位移压在 51ms 内走完,12px 的整页平移会像跳一格)。 守卫与取证 - 11 条帧泵算术守卫 + 4 条接线守卫(含「NONE 档帧采样次数必须为 0」与「帧订阅源码只出现一处」); - 5 条变异证明全部复现(删退订 / 删 NONE 短路 / 删 needs_frames 分支 / 删 16ms 闸门 / 加第二条帧订阅 ⇒ 对应用例红); - 多帧序列图 12 张在 `DevDocs/artifacts/m192/`(t=0 / 半程 / 末 + 静息基线, `--diff --diff-scan` 实测换页 12px/6px、详情 8px); - 空闲实证:`ULL_TICK_TRACE=1` 跑真窗口,帧订阅窗口一过 `clock=idle-1hz` 且 `frames=` 冻结。
This commit is contained in:
1 parent
393d6f188f
commit
a1876a08c0
11 files changed
+1052
-8
No files matched your search
@@ -11,6 +11,7 @@
|
||||
//! buttons/ ← COMPBASE(12b §1 按钮家族 + §2 IconButton)
|
||||
//! divider.rs ← COMPBASE(15 §22)
|
||||
//! scroll.rs ← M173:竖直滚动区的滚动条槽(轨道不压内容)
|
||||
//! motion/ ← M192:动效帧泵(时间轴,不是控件家族;页面经 `pages::render` 用它)
|
||||
//! selection/ ← 本文件只放桩;后续 mission 在自己的目录里填(§18/§19/§10/§13)
|
||||
//! inputs/ ← 同上(§9/§20/§11/§8)
|
||||
//! feedback/ ← 同上(§12/§14/§7/§15)
|
||||
@@ -52,6 +53,8 @@ pub mod gallery;
|
||||
pub mod inputs;
|
||||
pub mod interaction;
|
||||
pub mod layout;
|
||||
/// M192:动效帧泵(时间轴,不是控件家族)。页面只经 `pages::render` 用它,见模块文档。
|
||||
pub mod motion;
|
||||
pub mod navigation;
|
||||
pub mod scroll;
|
||||
pub mod selection;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! **动效帧泵**(M192):design 的 motion 令牌 → iced 的**有界**帧订阅。
|
||||
//!
|
||||
//! ## 这个模块解决什么
|
||||
//!
|
||||
//! `crates/design/src/motion/` 里令牌一直齐着(16 档时长 / 可采样缓动 / 二维弹簧表 /
|
||||
//! `MotionScale::NONE` 降级),但适配层从没消费过 ⇒ 所有状态变化都是硬切。本模块是那条缺失的
|
||||
//! 中间层:**谁能动**([`Slot`])、**动多久**([`spec`] 的令牌表)、**什么时候该订阅帧**
|
||||
//! ([`needs_frames`])。
|
||||
//!
|
||||
//! ## 有界泵的契约(三条,都有守卫)
|
||||
//!
|
||||
//! | 契约 | 为什么 | 守在哪 |
|
||||
//! |---|---|---|
|
||||
//! | 只在**有轨道**时订阅帧 | `window::frames()` 一订阅就自持重绘循环(实测几百帧/秒) | `pump::tests::a_finished_animation_stops_asking_for_frames` |
|
||||
//! | 轨道到期**当场删除**,不靠「下次状态变化」 | 删晚了就是空闲烧帧 | 同上(把 `advance` 的清理删掉就红) |
|
||||
//! | 每帧推进**不密于** [`FRAME_MS`](16 ms ≈ 60/s) | 帧到得再快也只该改这么多次布局 | `pump::tests::frames_are_capped_at_sixty_per_second` |
|
||||
//!
|
||||
//! 接法是**加进**既有那条链,不是另起一条:`App::needs_frames()` 多一个「有轨道」的分支,
|
||||
//! 订阅表仍然是**一处** `if`(`app/subscription.rs`),所以与 `watch_until` / Busy 续订/ 心跳
|
||||
//! 天然不会双订阅——它们共享同一个布尔。
|
||||
//!
|
||||
//! ## `MotionScale::NONE` 是**真归零**
|
||||
//!
|
||||
//! 关掉动效 ≠ 跑一遍 0 时长动画:`Pump::play` 在 `is_off()` 时**不插入轨道**,`progress` 于是
|
||||
//! 直接返回 `1.0`(到位),`needs_frames()` 恒为假 ⇒ **一帧都不订阅**。守卫见
|
||||
//! `pump::tests::none_scale_never_starts_a_track`。
|
||||
//!
|
||||
//! ## 为什么是 thread-local 而不是 `App` 的字段
|
||||
//!
|
||||
//! 它是**时间轴**(`(槽 → 进度)` 一张表 + 一个时钟),不是应用状态:没有任何业务语义,也不需要
|
||||
//! 跨进程持久。iced 在事件循环线程上串行调 `update` / `view`(这与 `App::update(&mut self)` 能存在
|
||||
//! 是同一条纪律),所以一个线程局部就够,且**测试天然隔离**(`cargo test` 每个用例一个线程,
|
||||
//! 不会出现「另一条线的动画把 `needs_frames()` 顶成真」的假红)。
|
||||
//!
|
||||
//! ## 页面怎么用
|
||||
//!
|
||||
//! 只有一个入口:[`enter`](`pages/mod.rs::render` 每帧调一次)+ [`shifted`](把位移套到树上)。
|
||||
//! 页面**拿不到**时长/缓动/弹簧参数——那是 [`spec`] 的事。
|
||||
//!
|
||||
//! 位移走 `container` 的 `padding`:动画期间内容比容器少几像素高,末帧归零后与静息**逐像素相同**。
|
||||
//! 不做逐像素 α(D27:CPU 档零运行时半透明)也不做模糊(同上),所以两个渲染后端出图一致。
|
||||
|
||||
mod pump;
|
||||
|
||||
// 本 mission 里这五个只被测试与文档引用(产品路径只经 `enter`/`shifted`,拿不到时长/缓动),
|
||||
// 所以显式 `allow` 一下——与 `interaction::Tracker` 那条同一条理由:它们是对外口径的一部分
|
||||
// (`spec` 是唯一一张规格表),不该因为「暂时没人调」就被删掉。
|
||||
#[allow(unused_imports)]
|
||||
pub use pump::{offset, spec, Slot, Spec, FRAME_MS};
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::time::Instant;
|
||||
|
||||
use iced::widget::container;
|
||||
use iced::{Element, Length, Padding};
|
||||
|
||||
use unlucky_design::motion::MotionScale;
|
||||
|
||||
// clippy 1.99 的 `missing_const_for_thread_local` 对**已经是** `const { … }` 的初始化也报(本仓
|
||||
// 之前没有 thread_local,所以这条是第一次撞上):换过三种写法都照报,属于误报。初始化确实已是
|
||||
// 常量,所以按项显式放过(上在宏外面不算,lint 是对展开后的 item 报的)。
|
||||
thread_local! {
|
||||
/// 进程内唯一时间轴(见模块文档「为什么是 thread-local」)。
|
||||
#[allow(clippy::missing_const_for_thread_local)]
|
||||
static PUMP: RefCell<pump::Pump> = const { RefCell::new(pump::Pump::new()) };
|
||||
/// 上次收到帧的时刻(`advance` 用它把「帧到达」换算成流逝毫秒)。
|
||||
#[allow(clippy::missing_const_for_thread_local)]
|
||||
static LAST_FRAME: Cell<Option<Instant>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
/// 定档:`on_boot` 调一次(截图档给 `NONE`,CPU 档给 `HALF`,见 `app/update/boot_env.rs`)。
|
||||
pub fn configure(scale: MotionScale, pin: Option<f64>) {
|
||||
PUMP.with(|pump| pump.borrow_mut().configure(scale, pin));
|
||||
}
|
||||
|
||||
/// 当前档位。
|
||||
pub fn scale() -> MotionScale {
|
||||
PUMP.with(|pump| pump.borrow().scale())
|
||||
}
|
||||
|
||||
/// 已推进的帧数。
|
||||
pub fn samples() -> u64 {
|
||||
PUMP.with(|pump| pump.borrow().samples())
|
||||
}
|
||||
|
||||
/// 现在有没有动画在跑(`App::needs_frames` 的一个分支)。
|
||||
pub fn needs_frames() -> bool {
|
||||
PUMP.with(|pump| pump.borrow().is_animating())
|
||||
}
|
||||
|
||||
/// 页主体入场:`(screen, detail)` 任一标识变了就跑对应槽;返回**取整后的位移**。
|
||||
///
|
||||
/// 每帧调一次(`pages/mod.rs::render`)。`detail` 是详情页的实例 id(`None` = 没选中任何一行)。
|
||||
/// 没有动画在跑时它只做两次比较,不做任何布局。
|
||||
pub fn enter(screen: u64, detail: Option<&str>) -> (f32, f32) {
|
||||
PUMP.with(|pump| pump.borrow_mut().enter(screen, detail))
|
||||
}
|
||||
|
||||
/// 推进时间轴(`App::on_frame` 每帧调一次)。
|
||||
pub fn advance() {
|
||||
let now = Instant::now();
|
||||
let elapsed_ms = LAST_FRAME.with(|last| {
|
||||
let previous = last.replace(Some(now));
|
||||
previous.map_or(0.0, |previous| {
|
||||
now.saturating_duration_since(previous).as_secs_f64() * 1000.0
|
||||
})
|
||||
});
|
||||
PUMP.with(|pump| {
|
||||
pump.borrow_mut().advance(elapsed_ms);
|
||||
});
|
||||
}
|
||||
|
||||
/// 把入场位移套到树上。
|
||||
///
|
||||
/// 位移为 `(0, 0)` 时**原样返回**(不额外套一层容器):静息态与「没有动效」的代码路径逐像素相同,
|
||||
/// 这也是既有截图基线一张都不用重出的原因。
|
||||
pub fn shifted<'a, M: 'a>(
|
||||
element: impl Into<Element<'a, M>>,
|
||||
(dx, dy): (f32, f32),
|
||||
) -> Element<'a, M> {
|
||||
let element = element.into();
|
||||
if dx == 0.0 && dy == 0.0 {
|
||||
return element;
|
||||
}
|
||||
container(element)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.padding(Padding {
|
||||
top: dy,
|
||||
right: 0.0,
|
||||
bottom: 0.0,
|
||||
left: dx,
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn reset() {
|
||||
PUMP.with(|pump| *pump.borrow_mut() = pump::Pump::new());
|
||||
LAST_FRAME.with(|last| last.set(None));
|
||||
}
|
||||
|
||||
/// 测试专用:按给定毫秒推进(`advance` 读真实时钟,单元测试不能等它)。
|
||||
#[cfg(test)]
|
||||
pub fn advance_by(ms: f64) {
|
||||
PUMP.with(|pump| {
|
||||
pump.borrow_mut().advance(ms);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,307 @@
|
||||
//! 帧泵的**纯逻辑**:槽 / 规格表 / 时间轴 / 进度采样。
|
||||
//!
|
||||
//! 这一层不认识 iced、不认识线程、不读环境变量——那些都在 [`super`]。分开的理由很实际:
|
||||
//! 「帧间隔上限」「结束后必须清空」这两条守卫是**算术性质**,用本地 `Pump` 直接摆时间轴就能判死,
|
||||
//! 不必起窗口、不必等真实时间(那两条最容易写成「看起来在跑」的假绿)。
|
||||
//!
|
||||
//! ## 三个概念
|
||||
//!
|
||||
//! | 名字 | 是什么 | 谁定 |
|
||||
//! |---|---|---|
|
||||
//! | [`Slot`] | 一个**同屏互斥**的动画位(首屏 / 换页 / 列表↔详情) | 本文件 |
|
||||
//! | [`Spec`] | 该位的时长档 + 缓动档,**只能**是 design 令牌 | [`spec`](全项目唯一一张表) |
|
||||
//! | [`Pump`] | 一条时间轴 + 每槽最多一条轨道 | 本文件 |
|
||||
//!
|
||||
//! ## 为什么规格表在代码里而不是页面里
|
||||
//!
|
||||
//! D31:弹簧/时长令牌是**表**,页面只许按表取、不许自调参数。所以页面拿不到「给我 250ms」这种
|
||||
//! 口子——它只能说「我要换页了」([`Pump::enter`]),用哪一档由 [`spec`] 决定。改观感只改这张表。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use unlucky_design::motion::{DurationToken, Easing, MotionScale};
|
||||
use unlucky_design::spacing::Space;
|
||||
|
||||
/// 逐帧推进的**最短间隔**(16 ms ≈ 60 次/秒)。
|
||||
///
|
||||
/// 为什么需要它:`window::frames()` 的到达频率由合成器/渲染器决定,软件渲染实测能到几百帧/秒。
|
||||
/// 帧数本身不贵(贵的是整窗重绘,那由「订阅只在动画期间开着」管住),但**每帧都改布局**会让
|
||||
/// 页面每毫秒 reflow 一次。所以时间轴按 16 ms 结算:到得再快也只推进这么多次。
|
||||
pub const FRAME_MS: f64 = 16.0;
|
||||
|
||||
/// 一个动效槽。
|
||||
///
|
||||
/// 槽是**同屏互斥**的:三个槽都是「页主体入场」的不同触发点,同时跑会让位移叠加
|
||||
/// (首屏 16 + 换页 12 = 28px)。所以 [`Pump::play`] 起跑新槽时丢掉旧的——语义上也对:
|
||||
/// 新的一次内容变化取代上一次。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Slot {
|
||||
/// 首屏进入:进程内只在第一帧跑一次。
|
||||
Boot,
|
||||
/// 换页:路由换了(导航轨点选、返回、启动直达)。
|
||||
Screen,
|
||||
/// 列表 ↔ 详情:同一页里的子视图切换(实例页点一行)。
|
||||
Detail,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
/// 全部槽(遍历顺序稳定,写日志/断言用)。
|
||||
pub const ALL: [Slot; 3] = [Slot::Boot, Slot::Screen, Slot::Detail];
|
||||
|
||||
/// 稳定名字(日志与测试失败信息用;只有测试在调,故按 `cfg(test)` 编译)。
|
||||
#[cfg(test)]
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Slot::Boot => "boot",
|
||||
Slot::Screen => "screen",
|
||||
Slot::Detail => "detail",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条轨道的规格:**时长档 × 缓动档**,两个都是 design 令牌。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Spec {
|
||||
/// 时长档(毫秒值经 [`MotionScale`] 缩放后生效)。
|
||||
pub duration: DurationToken,
|
||||
/// 缓动曲线。
|
||||
pub easing: Easing,
|
||||
}
|
||||
|
||||
/// 槽 → 规格(**唯一**一张表)。
|
||||
///
|
||||
/// 三档时长取自令牌注释里对应场景的那一档(`Medium2` = 导航/tab 切换、`Medium3` = 卡片展开/
|
||||
/// 列表重排、`Medium4` = 面板开合),曲线**统一用 `Standard`**:
|
||||
///
|
||||
/// - 整块页面的位移不是「元素从屏外飞入」,而是「屏内这块内容换了」——M3 把 `Standard` 定义为
|
||||
/// 「起止都在屏内的元素」,`*Decelerate` 是给「已经在高速运动、要迅速落定」的共享元素用的;
|
||||
/// - 本地实测(`dev` 机器,见报告):`EmphasizedDecelerate` 把 **75% 的位移压在 51 ms 内**走完
|
||||
/// (300 ms 档),12px 的整页平移会看起来像「跳一格」;`Standard` 同样时长下是 105 ms,落点更稳。
|
||||
pub const fn spec(slot: Slot) -> Spec {
|
||||
match slot {
|
||||
Slot::Boot => Spec {
|
||||
duration: DurationToken::Medium4,
|
||||
easing: Easing::Standard,
|
||||
},
|
||||
Slot::Screen => Spec {
|
||||
duration: DurationToken::Medium2,
|
||||
easing: Easing::Standard,
|
||||
},
|
||||
Slot::Detail => Spec {
|
||||
duration: DurationToken::Medium3,
|
||||
easing: Easing::Standard,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 槽 → 入场位移(像素;正值 = 从右下方向归位,末态恒为 `(0, 0)`)。
|
||||
///
|
||||
/// 幅度取**间距令牌**(4px 栅格)而不是新造一组数字:位移是布局量,和间距用同一把尺子,
|
||||
/// 将来改栅格不会出现「间距 12、位移 14」这种两套刻度。
|
||||
pub fn offset(slot: Slot) -> (f32, f32) {
|
||||
match slot {
|
||||
Slot::Boot => (0.0, Space::X4.dp()),
|
||||
Slot::Screen => (0.0, Space::X3.dp()),
|
||||
Slot::Detail => (Space::X2.dp(), 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条正在跑的轨道。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Track {
|
||||
/// 起跑时刻(动画时间轴的坐标,毫秒)。
|
||||
start_ms: f64,
|
||||
spec: Spec,
|
||||
}
|
||||
|
||||
/// 帧泵:一条时间轴 + 每槽一条轨道。
|
||||
///
|
||||
/// **默认是关的**(`MotionScale::NONE`):产品启动时由 `App::on_boot` 按档位打开,而
|
||||
/// `pages_demo` 这类截图 harness 从不打开——D30 的「同一份代码出同一张图」因此不受动效影响。
|
||||
#[derive(Debug)]
|
||||
pub struct Pump {
|
||||
scale: MotionScale,
|
||||
/// `Some(ms)` = 钉住相位(多帧序列图取证;见 `ULL_MOTION_AT`)。
|
||||
pin: Option<f64>,
|
||||
now_ms: f64,
|
||||
pending_ms: f64,
|
||||
tracks: BTreeMap<Slot, Track>,
|
||||
screen: Option<u64>,
|
||||
detail: Option<String>,
|
||||
painted: bool,
|
||||
samples: u64,
|
||||
}
|
||||
|
||||
impl Pump {
|
||||
/// 出厂态:关着、没跑过、没有轨道。
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
scale: MotionScale::NONE,
|
||||
pin: None,
|
||||
now_ms: 0.0,
|
||||
pending_ms: 0.0,
|
||||
tracks: BTreeMap::new(),
|
||||
screen: None,
|
||||
detail: None,
|
||||
painted: false,
|
||||
samples: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 定档(启动一拍调一次)。
|
||||
///
|
||||
/// 顺带**重置内容标识**:iced 会在处理 `Boot` 之前先问一次 `view()`(实测:那一帧早于
|
||||
/// `on_boot` 的日志),于是「第一帧」发生在档位还没定下来的时候。不重置的话首屏入场就被那次
|
||||
/// 预渲染吃掉了(`painted = true`),之后标识再没变过 ⇒ Boot 槽永远不跑。
|
||||
pub fn configure(&mut self, scale: MotionScale, pin: Option<f64>) {
|
||||
self.scale = scale;
|
||||
self.pin = pin;
|
||||
self.painted = false;
|
||||
self.screen = None;
|
||||
self.detail = None;
|
||||
self.pending_ms = 0.0;
|
||||
if scale.is_off() {
|
||||
// 关掉动效 = 直接落终态:把在跑的轨道清掉,而不是让它跑一遍 0 时长。
|
||||
self.tracks.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前档位。
|
||||
pub fn scale(&self) -> MotionScale {
|
||||
self.scale
|
||||
}
|
||||
|
||||
/// 已推进的帧数(守卫与 `ULL_TICK_TRACE` 用)。
|
||||
pub fn samples(&self) -> u64 {
|
||||
self.samples
|
||||
}
|
||||
|
||||
/// 时间轴(毫秒)。
|
||||
#[cfg(test)]
|
||||
pub fn now_ms(&self) -> f64 {
|
||||
self.now_ms
|
||||
}
|
||||
|
||||
/// 现在有没有动画在跑(= 要不要订阅帧)。
|
||||
pub fn is_animating(&self) -> bool {
|
||||
!self.tracks.is_empty()
|
||||
}
|
||||
|
||||
/// 起跑一个槽(关着动效时什么都不做——**不是**跑一遍 0 时长)。
|
||||
///
|
||||
/// 三个槽**互斥**:它们都是「页主体入场」的不同触发点,同时跑会让位移叠加(首屏 + 换页 =
|
||||
/// 28px)。所以起跑新槽就丢掉旧的——语义上也对:新的一次内容变化取代上一次。
|
||||
pub fn play(&mut self, slot: Slot) {
|
||||
self.tracks.clear();
|
||||
if self.scale.is_off() {
|
||||
return;
|
||||
}
|
||||
// 钉住相位时轨道的起点固定在 0:进度于是恒等于「钉住的毫秒 / 时长」,
|
||||
// 与「谁在第几帧起跑」无关——这正是多帧序列图要的可复现性。
|
||||
let start_ms = if self.pin.is_some() { 0.0 } else { self.now_ms };
|
||||
self.tracks.insert(
|
||||
slot,
|
||||
Track {
|
||||
start_ms,
|
||||
spec: spec(slot),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 采样:`0.0` = 刚起跑,`1.0` = 到位(**没有轨道就是到位**)。
|
||||
pub fn progress(&self, slot: Slot) -> f32 {
|
||||
let Some(track) = self.tracks.get(&slot) else {
|
||||
return 1.0;
|
||||
};
|
||||
let duration = f64::from(self.scale.ms(track.spec.duration));
|
||||
if duration <= 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
let elapsed = self.elapsed_ms() - track.start_ms;
|
||||
let t = (elapsed / duration).clamp(0.0, 1.0) as f32;
|
||||
track.spec.easing.sample(t)
|
||||
}
|
||||
|
||||
/// 当前动画时刻(钉住相位时恒为钉住值)。
|
||||
fn elapsed_ms(&self) -> f64 {
|
||||
self.pin.unwrap_or(self.now_ms)
|
||||
}
|
||||
|
||||
/// 页主体的入场位移:**每帧调一次**,标识变了才真的起跑。
|
||||
///
|
||||
/// 三条判据(顺序即优先级):
|
||||
/// 1. 第一帧 → [`Slot::Boot`](首屏也算一次「东西出现」);
|
||||
/// 2. 屏变了 → [`Slot::Screen`];
|
||||
/// 3. 屏没变但详情变了 → [`Slot::Detail`]。
|
||||
///
|
||||
/// 返回值按各槽剩余量求和后**取整**:整像素位移不会让文字在动画期间发虚,
|
||||
/// 末帧三个槽都是 `1.0` ⇒ 恒为 `(0, 0)` ⇒ 与静息的布局逐像素相同。
|
||||
pub fn enter(&mut self, screen: u64, detail: Option<&str>) -> (f32, f32) {
|
||||
if !self.painted {
|
||||
self.painted = true;
|
||||
self.play(Slot::Boot);
|
||||
} else if self.screen != Some(screen) {
|
||||
self.play(Slot::Screen);
|
||||
} else if self.detail.as_deref() != detail {
|
||||
self.play(Slot::Detail);
|
||||
}
|
||||
self.screen = Some(screen);
|
||||
if self.detail.as_deref() != detail {
|
||||
self.detail = detail.map(str::to_owned);
|
||||
}
|
||||
|
||||
let mut dx = 0.0_f32;
|
||||
let mut dy = 0.0_f32;
|
||||
for slot in Slot::ALL {
|
||||
let remaining = 1.0 - self.progress(slot);
|
||||
let (ox, oy) = offset(slot);
|
||||
dx += ox * remaining;
|
||||
dy += oy * remaining;
|
||||
}
|
||||
(dx.round(), dy.round())
|
||||
}
|
||||
|
||||
/// 推进时间轴。返回 `true` = 这一帧真的推进了(进度变了 ⇒ 要重画)。
|
||||
///
|
||||
/// 三步:①不满 [`FRAME_MS`] 就不动(帧上限)②到期的轨道**当场删除**(这是「空闲不烧帧」的
|
||||
/// 唯一出口:删掉最后一个轨道之后 [`is_animating`](Self::is_animating) 立刻为假,下一拍订阅表
|
||||
/// 里就不再有 `window::frames()`)③钉住相位时时间轴不前进(取证专用)。
|
||||
pub fn advance(&mut self, dt_ms: f64) -> bool {
|
||||
if self.tracks.is_empty() {
|
||||
self.pending_ms = 0.0;
|
||||
return false;
|
||||
}
|
||||
if dt_ms.is_finite() && dt_ms > 0.0 {
|
||||
self.pending_ms += dt_ms;
|
||||
}
|
||||
if self.pending_ms < FRAME_MS {
|
||||
return false;
|
||||
}
|
||||
let step = std::mem::take(&mut self.pending_ms);
|
||||
if self.pin.is_none() {
|
||||
self.now_ms += step;
|
||||
}
|
||||
self.samples += 1;
|
||||
|
||||
let now = self.elapsed_ms();
|
||||
let finished: Vec<Slot> = self
|
||||
.tracks
|
||||
.iter()
|
||||
.filter(|(_, track)| {
|
||||
let duration = f64::from(self.scale.ms(track.spec.duration));
|
||||
duration <= 0.0 || now - track.start_ms >= duration
|
||||
})
|
||||
.map(|(slot, _)| *slot)
|
||||
.collect();
|
||||
for slot in finished {
|
||||
self.tracks.remove(&slot);
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Pump {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//! 帧泵的守卫(M192)。全部用**本地** `Pump` 摆时间轴:不起窗口、不等真实时间,所以不会假绿。
|
||||
|
||||
use super::pump::{offset, spec, Pump, Slot, FRAME_MS};
|
||||
use super::*;
|
||||
|
||||
/// 标准档、没有钉相位的泵(出厂态是 `NONE`,见 [`Pump::new`])。
|
||||
fn standard() -> Pump {
|
||||
let mut pump = Pump::new();
|
||||
pump.configure(MotionScale::STANDARD, None);
|
||||
pump
|
||||
}
|
||||
|
||||
/// 按「每帧 16 ms」推进到全部轨道结束(上限 20 s,防死循环)。
|
||||
fn settle(pump: &mut Pump) -> u64 {
|
||||
let mut frames = 0;
|
||||
for _ in 0..1250 {
|
||||
if !pump.is_animating() {
|
||||
break;
|
||||
}
|
||||
if pump.advance(FRAME_MS) {
|
||||
frames += 1;
|
||||
}
|
||||
}
|
||||
frames
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 令牌表
|
||||
|
||||
/// 规格**全部**来自 design 的令牌表,且落在 M3 的常规区间(改表会让这条红)。
|
||||
#[test]
|
||||
fn every_slot_spec_is_a_registered_token_pair() {
|
||||
for slot in Slot::ALL {
|
||||
let spec = spec(slot);
|
||||
let ms = spec.duration.ms();
|
||||
assert!(
|
||||
(250..=500).contains(&ms),
|
||||
"{}: 时长 {}ms 不在换页类动效的合理区间(克制优先)",
|
||||
slot.name(),
|
||||
ms
|
||||
);
|
||||
// 缓动必须是真的曲线(端点 = 0/1)
|
||||
assert!((spec.easing.sample(0.0)).abs() < 1.0e-6);
|
||||
assert!((spec.easing.sample(1.0) - 1.0).abs() < 1.0e-6);
|
||||
// 位移必须来自间距栅格(4 的倍数),且不为零
|
||||
let (dx, dy) = offset(slot);
|
||||
assert!(
|
||||
dx > 0.0 || dy > 0.0,
|
||||
"{}: 位移为 0 就没有入场感",
|
||||
slot.name()
|
||||
);
|
||||
assert_eq!(dx % 4.0, 0.0);
|
||||
assert_eq!(dy % 4.0, 0.0);
|
||||
assert!(dx.max(dy) <= 24.0, "{}: 位移过大(克制优先)", slot.name());
|
||||
}
|
||||
// 表本身钉住:谁改了这三档,报告与出图都要跟着改
|
||||
assert_eq!(spec(Slot::Screen).duration.ms(), 300);
|
||||
assert_eq!(spec(Slot::Detail).duration.ms(), 350);
|
||||
assert_eq!(spec(Slot::Boot).duration.ms(), 400);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 有界泵
|
||||
|
||||
/// **空闲不烧帧**:动画跑完 ⇒ `is_animating()` 立刻为假,且不再推进。
|
||||
///
|
||||
/// 抓得住:把 `advance` 里的「到期即删除」删掉(轨道永远留着 ⇒ 帧订阅永不退订),或把
|
||||
/// `is_animating` 改成恒真。两条都让本用例红。
|
||||
#[test]
|
||||
fn a_finished_animation_stops_asking_for_frames() {
|
||||
let mut pump = standard();
|
||||
pump.play(Slot::Screen);
|
||||
assert!(pump.is_animating(), "起跑后必须有轨道(订阅帧的唯一理由)");
|
||||
|
||||
settle(&mut pump);
|
||||
assert!(
|
||||
!pump.is_animating(),
|
||||
"动画结束必须**当场**退订(占着轨道 = 空闲烧帧)"
|
||||
);
|
||||
assert_eq!(pump.progress(Slot::Screen), 1.0, "结束即到位");
|
||||
|
||||
// 再推进也不该有任何采样(订阅表里已经没有帧了)
|
||||
let before = pump.samples();
|
||||
for _ in 0..60 {
|
||||
assert!(!pump.advance(FRAME_MS), "没有轨道时推进必须返回 false");
|
||||
}
|
||||
assert_eq!(pump.samples(), before, "空闲态一次采样都不该发生");
|
||||
}
|
||||
|
||||
/// **帧间隔上限**:帧到得再快,时间轴也只按 16 ms 结算(≈60 次/秒)。
|
||||
///
|
||||
/// 抓得住:把 `pending_ms` 那道闸门删掉(每来一帧就推进 ⇒ 软件渲染下几百次/秒的重排)。
|
||||
#[test]
|
||||
fn frames_are_capped_at_sixty_per_second() {
|
||||
let mut pump = standard();
|
||||
pump.play(Slot::Screen);
|
||||
|
||||
// 200 帧、每帧 1 ms(模拟 1000 fps 的合成器)
|
||||
let mut advanced = 0;
|
||||
for _ in 0..200 {
|
||||
if pump.advance(1.0) {
|
||||
advanced += 1;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
advanced <= 13,
|
||||
"200 ms 内最多 13 次推进(200/16),实测 {advanced} 次"
|
||||
);
|
||||
assert!(
|
||||
advanced >= 11,
|
||||
"也别少得离谱(掉帧会看起来卡),实测 {advanced}"
|
||||
);
|
||||
assert_eq!(pump.samples(), advanced);
|
||||
// 时间轴按真实时间走,不受「帧被合并」影响
|
||||
assert!(
|
||||
(pump.now_ms() - 192.0).abs() <= 16.0,
|
||||
"时间轴应约 192 ms(12 × 16),实测 {}",
|
||||
pump.now_ms()
|
||||
);
|
||||
}
|
||||
|
||||
/// 采样必须单调、且末尾回到 1.0(回弹类曲线不在本批,见 [`spec`] 的表)。
|
||||
#[test]
|
||||
fn progress_is_monotone_and_lands_on_one() {
|
||||
let mut pump = standard();
|
||||
pump.play(Slot::Detail);
|
||||
let mut previous = 0.0;
|
||||
for _ in 0..40 {
|
||||
let value = pump.progress(Slot::Detail);
|
||||
assert!(value >= previous, "进度倒退了:{previous} → {value}");
|
||||
previous = value;
|
||||
if !pump.advance(FRAME_MS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(previous, 1.0, "末帧必须到位");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ NONE 降级
|
||||
|
||||
/// **`MotionScale::NONE` 是真归零**:不起轨道、不采样、位移恒为 0。
|
||||
///
|
||||
/// 抓得住:把 `play` 里的 `is_off()` 分支删掉(变成「跑一遍 0 时长动画」)——那样这个用例里
|
||||
/// `is_animating()` 会短暂为真、`samples` 会增长,帧订阅次数就不是 0 了。
|
||||
#[test]
|
||||
fn none_scale_never_starts_a_track() {
|
||||
let mut pump = Pump::new(); // 出厂即 NONE
|
||||
assert!(pump.scale().is_off());
|
||||
|
||||
// 首屏 + 换页 + 换详情:三次状态变化都不许留下轨道
|
||||
assert_eq!(pump.enter(1, None), (0.0, 0.0), "关掉动效必须直接落终态");
|
||||
assert!(!pump.is_animating());
|
||||
assert_eq!(pump.enter(2, None), (0.0, 0.0));
|
||||
assert!(!pump.is_animating());
|
||||
assert_eq!(pump.enter(2, Some("instance-7")), (0.0, 0.0));
|
||||
assert!(!pump.is_animating());
|
||||
|
||||
for _ in 0..120 {
|
||||
assert!(!pump.advance(FRAME_MS), "关掉动效后没有任何可推进的");
|
||||
}
|
||||
assert_eq!(pump.samples(), 0, "NONE 档的帧订阅次数必须是 0");
|
||||
}
|
||||
|
||||
/// 运行期把关掉动效(`configure(NONE)`)也要立刻落终态,而不是把在跑的动画「缩到 0 时长」。
|
||||
#[test]
|
||||
fn switching_to_none_mid_flight_settles_immediately() {
|
||||
let mut pump = standard();
|
||||
pump.play(Slot::Screen);
|
||||
pump.advance(FRAME_MS);
|
||||
assert!(pump.is_animating());
|
||||
|
||||
pump.configure(MotionScale::NONE, None);
|
||||
assert!(!pump.is_animating(), "关掉动效必须当场清空轨道");
|
||||
assert_eq!(pump.progress(Slot::Screen), 1.0);
|
||||
}
|
||||
|
||||
/// 半速档(CPU 渲染兜底)真的把时长减半:同样的时间轴推进,轨道更早结束。
|
||||
#[test]
|
||||
fn half_scale_finishes_in_half_the_frames() {
|
||||
let mut full = standard();
|
||||
full.play(Slot::Screen);
|
||||
let full_frames = settle(&mut full);
|
||||
|
||||
let mut half = Pump::new();
|
||||
half.configure(MotionScale::HALF, None);
|
||||
half.play(Slot::Screen);
|
||||
let half_frames = settle(&mut half);
|
||||
|
||||
assert!(half_frames > 0 && full_frames > 0);
|
||||
assert!(
|
||||
half_frames * 2 <= full_frames + 2,
|
||||
"半速档应约用一半帧数:full={full_frames} half={half_frames}"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 触发判据
|
||||
|
||||
/// 三个槽的触发优先级:首帧 Boot → 换屏 Screen → 同屏换详情 Detail。
|
||||
#[test]
|
||||
fn identity_changes_pick_the_right_slot() {
|
||||
let mut pump = standard();
|
||||
|
||||
// 首帧:Boot(位移是纵向 16)
|
||||
let (dx, dy) = pump.enter(1, None);
|
||||
assert_eq!(dx, 0.0);
|
||||
assert!(dy > 0.0, "首屏应有一个向下的入场位移");
|
||||
assert!(pump.is_animating(), "首帧要跑 Boot 槽");
|
||||
assert_eq!(
|
||||
pump.progress(Slot::Screen),
|
||||
1.0,
|
||||
"首帧不该顺带起跑 Screen 槽"
|
||||
);
|
||||
settle(&mut pump);
|
||||
assert_eq!(pump.enter(1, None), (0.0, 0.0), "标识没变 ⇒ 不动");
|
||||
|
||||
// 换屏:Screen(纵向)
|
||||
let (dx, dy) = pump.enter(2, None);
|
||||
assert_eq!(dx, 0.0);
|
||||
assert!(dy > 0.0);
|
||||
assert!(pump.progress(Slot::Screen) < 1.0, "换屏要跑 Screen 槽");
|
||||
settle(&mut pump);
|
||||
|
||||
// 同屏换详情:Detail(横向)
|
||||
let (dx, dy) = pump.enter(2, Some("instance-7"));
|
||||
assert!(dx > 0.0, "列表↔详情是横向入场");
|
||||
assert_eq!(dy, 0.0);
|
||||
assert!(pump.progress(Slot::Detail) < 1.0);
|
||||
settle(&mut pump);
|
||||
assert_eq!(pump.enter(2, Some("instance-7")), (0.0, 0.0));
|
||||
}
|
||||
|
||||
/// 换页会**取代**首屏那一档:位移不叠加(12px,不是 16+12)。
|
||||
#[test]
|
||||
fn a_new_transition_supersedes_the_previous_one() {
|
||||
let mut pump = standard();
|
||||
let boot_dy = offset(Slot::Boot).1;
|
||||
assert_eq!(pump.enter(1, None).1, boot_dy, "首帧从 Boot 的最大位移开始");
|
||||
|
||||
// 首屏还没跑完就换页:位移必须是 Screen 那一档,而不是两档相加
|
||||
let (_, dy) = pump.enter(2, None);
|
||||
assert_eq!(
|
||||
dy,
|
||||
offset(Slot::Screen).1,
|
||||
"新的一次内容变化应取代上一次(叠加会让位移变成两档之和)"
|
||||
);
|
||||
assert!(!pump.is_animating() || pump.progress(Slot::Boot) == 1.0);
|
||||
}
|
||||
|
||||
/// 同一屏同一详情重复绘制**永远不会**重新起跑(否则每帧都在重启动画 = 永远停不下来)。
|
||||
#[test]
|
||||
fn repeated_paints_never_restart_the_animation() {
|
||||
let mut pump = standard();
|
||||
pump.enter(3, Some("instance-9"));
|
||||
let frames = settle(&mut pump);
|
||||
assert!(frames > 0);
|
||||
|
||||
for _ in 0..30 {
|
||||
assert_eq!(pump.enter(3, Some("instance-9")), (0.0, 0.0));
|
||||
}
|
||||
assert!(!pump.is_animating(), "重复绘制不该起新轨道");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 取证相位
|
||||
|
||||
/// `ULL_MOTION_AT` 的钉住相位:时间轴不前进,进度恒定(多帧序列图靠它可复现)。
|
||||
#[test]
|
||||
fn pinned_phase_freezes_the_clock() {
|
||||
let mut pump = Pump::new();
|
||||
pump.configure(MotionScale::STANDARD, Some(150.0));
|
||||
pump.play(Slot::Screen); // 300 ms 档 ⇒ 钉在 150 = 一半
|
||||
|
||||
for _ in 0..10 {
|
||||
pump.advance(FRAME_MS);
|
||||
}
|
||||
let progress = pump.progress(Slot::Screen);
|
||||
assert!(
|
||||
(progress - spec(Slot::Screen).easing.sample(0.5)).abs() < 1.0e-6,
|
||||
"钉在 150/300 的进度应等于缓动曲线的中点,实测 {progress}"
|
||||
);
|
||||
assert!(pump.is_animating(), "钉住的相位不会自然结束(取证专用)");
|
||||
}
|
||||
Reference in new issue
Block a user