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
@@ -56,6 +56,9 @@ pub(super) const TICK: std::time::Duration = std::time::Duration::from_millis(TI
|
|||||||
/// 实现用的是 `Task::perform(async { sleep(1s) })` 的自续链:[`App::heartbeat`]。它每秒只唤醒
|
/// 实现用的是 `Task::perform(async { sleep(1s) })` 的自续链:[`App::heartbeat`]。它每秒只唤醒
|
||||||
/// 一次、只触发**一帧**重绘,而 `frames()` 是每秒几百帧。为什么不用 `iced::time::every`:
|
/// 一次、只触发**一帧**重绘,而 `frames()` 是每秒几百帧。为什么不用 `iced::time::every`:
|
||||||
/// 它挂在 tokio/smol 后端下,本 crate 的执行器是 thread-pool(Cargo.toml 不在 scope)。
|
/// 它挂在 tokio/smol 后端下,本 crate 的执行器是 thread-pool(Cargo.toml 不在 scope)。
|
||||||
|
///
|
||||||
|
/// **M192 的第三条时钟不在这里**:动效(`components::motion`)也只在「有轨道」时借用帧订阅,
|
||||||
|
/// 最后一个轨道到期就清空 ⇒ 下一拍 [`App::needs_frames`] 自动退回这条 1 Hz 心跳。
|
||||||
pub(super) const IDLE_TICK: std::time::Duration = std::time::Duration::from_secs(1);
|
pub(super) const IDLE_TICK: std::time::Duration = std::time::Duration::from_secs(1);
|
||||||
|
|
||||||
/// 派发指令后保持帧订阅的观察窗(见 [`App::watch_until`])。
|
/// 派发指令后保持帧订阅的观察窗(见 [`App::watch_until`])。
|
||||||
|
|||||||
@@ -11,10 +11,14 @@
|
|||||||
//! | 条件 | 为什么需要 33 ms 节拍 |
|
//! | 条件 | 为什么需要 33 ms 节拍 |
|
||||||
//! |---|---|
|
//! |---|---|
|
||||||
//! | 截图通道 | 第 3 帧要落盘 |
|
//! | 截图通道 | 第 3 帧要落盘 |
|
||||||
|
//! | **有动画在跑**(M192) | 动效时间轴要逐帧采样(帧泵自己按 16 ms 结算,见 `components::motion`) |
|
||||||
//! | 有 `Busy` / `Running` 实例 | 启动进度、日志尾随都靠 `backend.tick` 推进 |
|
//! | 有 `Busy` / `Running` 实例 | 启动进度、日志尾随都靠 `backend.tick` 推进 |
|
||||||
//! | 有未过期的 toast | toast 按毫秒倒计时 |
|
//! | 有未过期的 toast | toast 按毫秒倒计时 |
|
||||||
//! | 刚派发过指令(3 s 观察窗) | 真 host 的异步事件要靠节拍抽出来 |
|
//! | 刚派发过指令(3 s 观察窗) | 真 host 的异步事件要靠节拍抽出来 |
|
||||||
//!
|
//!
|
||||||
|
//! 上面五条**共享同一个布尔**:订阅表里 `window::frames()` 只出现一处(写在同一个 `if` 里),
|
||||||
|
//! 所以「动画」与「观察窗 / Busy」同时成立时也只有一条订阅——不双订阅靠的是结构,不是判重。
|
||||||
|
//!
|
||||||
//! 空闲态**一条帧消息都不来** ⇒ 窗口不重绘、`refresh_view` 不跑(配合 `view_dirty`)。
|
//! 空闲态**一条帧消息都不来** ⇒ 窗口不重绘、`refresh_view` 不跑(配合 `view_dirty`)。
|
||||||
//!
|
//!
|
||||||
//! ## 空闲态的时钟从哪来(M90 r3 / P2-2)
|
//! ## 空闲态的时钟从哪来(M90 r3 / P2-2)
|
||||||
@@ -43,6 +47,7 @@ use std::time::Instant;
|
|||||||
|
|
||||||
use iced::{window, Subscription};
|
use iced::{window, Subscription};
|
||||||
|
|
||||||
|
use super::components::motion;
|
||||||
use super::message::Message;
|
use super::message::Message;
|
||||||
use super::state::App;
|
use super::state::App;
|
||||||
|
|
||||||
@@ -85,6 +90,8 @@ impl App {
|
|||||||
/// 现在要不要**时钟**(见模块文档的表)。
|
/// 现在要不要**时钟**(见模块文档的表)。
|
||||||
pub(super) fn needs_frames(&self) -> bool {
|
pub(super) fn needs_frames(&self) -> bool {
|
||||||
self.has_screenshot()
|
self.has_screenshot()
|
||||||
|
// M192:只有动画在跑时才多这一条(帧泵清空轨道后立刻为假 ⇒ 退回 1 Hz 心跳)。
|
||||||
|
|| motion::needs_frames()
|
||||||
|| !self.state.toasts.is_empty()
|
|| !self.state.toasts.is_empty()
|
||||||
|| !self.state.running().is_empty()
|
|| !self.state.running().is_empty()
|
||||||
// M175:Java 扫描进行中(进度 3/7 要动起来,否则用户只看到「正在扫描」卡着)。
|
// M175:Java 扫描进行中(进度 3/7 要动起来,否则用户只看到「正在扫描」卡着)。
|
||||||
@@ -94,3 +101,6 @@ impl App {
|
|||||||
.is_some_and(|deadline| Instant::now() < deadline)
|
.is_some_and(|deadline| Instant::now() < deadline)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
//! 帧订阅的**有界**守卫(M192):动画期间订阅、结束即退订、关掉动效一帧都不订阅。
|
||||||
|
//!
|
||||||
|
//! 这一组是**接线级**的(走真的 `App::needs_frames`),与 `components::motion::tests` 的算术守卫
|
||||||
|
//! 互补:那边证明帧泵会清空轨道,这边证明清空之后订阅表真的跟着变干净。
|
||||||
|
//!
|
||||||
|
//! 时间轴用 `motion::advance_by` 摆(不等真实时钟),每个用例在自己的线程上跑,互不干扰。
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::app::components::motion::{Slot, FRAME_MS};
|
||||||
|
use crate::cli::Options;
|
||||||
|
use crate::theme::Families;
|
||||||
|
use unlucky_design::color::SchemeFlavor;
|
||||||
|
use unlucky_design::motion::MotionScale;
|
||||||
|
use unlucky_presenter::mock::MockBackend;
|
||||||
|
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||||
|
|
||||||
|
const FAMILIES: Families = Families {
|
||||||
|
text: "Segoe UI",
|
||||||
|
display: "Segoe UI",
|
||||||
|
mono: "Consolas",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。
|
||||||
|
fn headless() -> App {
|
||||||
|
App::with_presenter(
|
||||||
|
Options::default(),
|
||||||
|
FAMILIES,
|
||||||
|
SchemeFlavor::Light,
|
||||||
|
Box::new(MockBackend::new()),
|
||||||
|
SettingsBridge::memory_only(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把时间轴推到全部轨道结束(每步 16 ms,上限 20 s)。
|
||||||
|
fn run_until_idle() {
|
||||||
|
for _ in 0..1250 {
|
||||||
|
if !motion::needs_frames() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
motion::advance_by(FRAME_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **动画期间订阅帧、结束立刻退订**(空闲回到 1 Hz 心跳)。
|
||||||
|
///
|
||||||
|
/// 变异证明:把 `App::needs_frames` 里的 `motion::needs_frames()` 分支删掉 ⇒ 第二条断言红;
|
||||||
|
/// 把 `Pump::advance` 的「到期即删除」删掉 ⇒ 第三条断言红(跑完还在订阅)。
|
||||||
|
#[test]
|
||||||
|
fn frames_are_subscribed_while_animating_and_dropped_afterwards() {
|
||||||
|
motion::reset();
|
||||||
|
let mut app = headless();
|
||||||
|
app.watch_until = None;
|
||||||
|
assert!(!app.needs_frames(), "前提:空闲交互态本来就不订阅帧");
|
||||||
|
|
||||||
|
motion::configure(MotionScale::STANDARD, None);
|
||||||
|
// 首帧:页面主体入场(Boot 槽)
|
||||||
|
motion::enter(1, Some("survival"));
|
||||||
|
assert!(motion::needs_frames(), "起跑后帧泵必须有轨道");
|
||||||
|
assert!(app.needs_frames(), "有动画在跑时必须订阅帧");
|
||||||
|
|
||||||
|
run_until_idle();
|
||||||
|
assert!(!motion::needs_frames(), "动画跑完帧泵必须清空");
|
||||||
|
assert!(
|
||||||
|
!app.needs_frames(),
|
||||||
|
"动画结束后必须退回 1 Hz 心跳(占着帧订阅 = 空闲烧帧)"
|
||||||
|
);
|
||||||
|
motion::reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **`MotionScale::NONE` 真的零订阅**:不是「跑一遍 0 时长动画」。
|
||||||
|
///
|
||||||
|
/// 变异证明:把 `Pump::play` 的 `is_off()` 分支删掉 ⇒ 帧订阅次数非 0,本用例红。
|
||||||
|
#[test]
|
||||||
|
fn no_scale_requests_no_frames_at_all() {
|
||||||
|
motion::reset();
|
||||||
|
let app = headless();
|
||||||
|
motion::configure(MotionScale::NONE, None);
|
||||||
|
|
||||||
|
// 三种状态变化:首屏 / 换页 / 换详情
|
||||||
|
assert_eq!(motion::enter(1, None), (0.0, 0.0));
|
||||||
|
assert_eq!(motion::enter(2, Some("survival")), (0.0, 0.0));
|
||||||
|
assert_eq!(motion::enter(2, Some("creative")), (0.0, 0.0));
|
||||||
|
run_until_idle();
|
||||||
|
|
||||||
|
assert!(!motion::needs_frames());
|
||||||
|
assert!(!app.needs_frames(), "关掉动效后一帧都不该订阅");
|
||||||
|
assert_eq!(motion::samples(), 0, "NONE 档的帧采样次数必须是 0");
|
||||||
|
motion::reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 帧订阅在源码里**只出现一处**:五条理由共享同一个布尔,所以不存在「动画与 Busy 各订一条」。
|
||||||
|
///
|
||||||
|
/// 这是结构守卫(扫自己的源码):谁另起一条 `window::frames()`,本用例立刻红。
|
||||||
|
#[test]
|
||||||
|
fn there_is_exactly_one_frame_subscription() {
|
||||||
|
let source = include_str!("../subscription.rs");
|
||||||
|
let product = source.split("#[cfg(test)]").next().unwrap_or(source);
|
||||||
|
let code = product
|
||||||
|
.lines()
|
||||||
|
.filter(|line| !line.trim_start().starts_with("//"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(
|
||||||
|
"
|
||||||
|
",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
code.matches("window::frames()").count(),
|
||||||
|
1,
|
||||||
|
"`window::frames()` 只许出现一处代码(五条理由共享同一个 if)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 槽枚举的可见性检查:`Slot` 只经帧泵导出(页面拿不到「给我 250ms」这种口子)。
|
||||||
|
#[test]
|
||||||
|
fn slots_are_the_three_documented_ones() {
|
||||||
|
assert_eq!(Slot::ALL.len(), 3);
|
||||||
|
assert_eq!(Slot::ALL.map(Slot::name), ["boot", "screen", "detail"]);
|
||||||
|
}
|
||||||
@@ -20,11 +20,14 @@
|
|||||||
//!
|
//!
|
||||||
//! | 场景 | 时钟 | 周期 | 谁触发 |
|
//! | 场景 | 时钟 | 周期 | 谁触发 |
|
||||||
//! |---|---|---|---|
|
//! |---|---|---|---|
|
||||||
//! | 截图 / 有 Busy-Running 实例 / 有 toast / 派发后 3 s 观察窗 | `window::frames()` + 闸门 | 33 ms | [`App::needs_frames`] |
|
//! | 截图 / **有动画在跑(M192)** / 有 Busy-Running 实例 / 有 toast / 派发后 3 s 观察窗 | `window::frames()` + 闸门 | 33 ms | [`App::needs_frames`] |
|
||||||
//! | 其余(空闲) | `Heartbeat` 自续链 | 1 s | [`App::heartbeat`] |
|
//! | 其余(空闲) | `Heartbeat` 自续链 | 1 s | [`App::heartbeat`] |
|
||||||
//!
|
//!
|
||||||
//! 两条时钟不会同时干活:心跳在 `needs_frames()` 为真时**只续排、不推进**,忙碌结束自动接回。
|
//! 两条时钟不会同时干活:心跳在 `needs_frames()` 为真时**只续排、不推进**,忙碌结束自动接回。
|
||||||
|
//! 动效那条**不新开循环**:它只是给同一个布尔加一个分支(有轨道才为真),轨道到期当场清空,
|
||||||
|
//! 下一拍就退回心跳(见 `components::motion`)。
|
||||||
|
|
||||||
|
use crate::app::components::motion;
|
||||||
use crate::app::diag::diag;
|
use crate::app::diag::diag;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
@@ -183,6 +186,10 @@ impl App {
|
|||||||
fn on_boot(&mut self) -> Task<Message> {
|
fn on_boot(&mut self) -> Task<Message> {
|
||||||
let mut tasks = vec![window::latest().map(Message::WindowReady)];
|
let mut tasks = vec![window::latest().map(Message::WindowReady)];
|
||||||
|
|
||||||
|
// M192:给动效帧泵定档(截图档关、CPU 档半速、GPU 档正常;`ULL_MOTION*` 可覆盖)。
|
||||||
|
// 必须在第一次 `view()` 之前:页面主体的入场由 `pages::render` 采样,配置晚了首屏就没有动效。
|
||||||
|
self.configure_motion();
|
||||||
|
|
||||||
// M117:启动日志一行 —— `[auth] msa client_id=configured|missing source=env|build|none`。
|
// M117:启动日志一行 —— `[auth] msa client_id=configured|missing source=env|build|none`。
|
||||||
// 判据(配没配 + 从哪来)在 `core::auth::microsoft::client_id_status()`,零网络;
|
// 判据(配没配 + 从哪来)在 `core::auth::microsoft::client_id_status()`,零网络;
|
||||||
// 本 crate 不直接依赖 core,所以经 `unlucky_cli`(已是本 crate 的依赖)出面,
|
// 本 crate 不直接依赖 core,所以经 `unlucky_cli`(已是本 crate 的依赖)出面,
|
||||||
@@ -242,6 +249,25 @@ impl App {
|
|||||||
Task::batch(tasks)
|
Task::batch(tasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// M192:给帧泵定档(策略全在 [`boot_env::motion_scale`],这里只搬输入)。
|
||||||
|
fn configure_motion(&mut self) {
|
||||||
|
let pin = boot_env::env_motion_pin();
|
||||||
|
let gpu = matches!(
|
||||||
|
super::backend::effective_kind(),
|
||||||
|
Some(crate::renderer::Kind::Wgpu)
|
||||||
|
);
|
||||||
|
let scale = boot_env::motion_scale(self.options.screenshot.is_some(), gpu, pin.is_some());
|
||||||
|
motion::configure(scale, pin);
|
||||||
|
// 打**帧泵实际拿到的**档位(不是刚算出的那个):配置没生效时这一行会露馅。
|
||||||
|
diag!(
|
||||||
|
"[motion] scale={:.2} pin={:?} gpu={} screenshot={}",
|
||||||
|
motion::scale().factor(),
|
||||||
|
pin,
|
||||||
|
gpu,
|
||||||
|
self.options.screenshot.is_some()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 空闲心跳:只有「不需要帧节拍」时才推进一拍,然后**无条件续排**下一拍。
|
/// 空闲心跳:只有「不需要帧节拍」时才推进一拍,然后**无条件续排**下一拍。
|
||||||
///
|
///
|
||||||
/// 为什么无条件续排:忙碌结束(游戏退出、toast 过期、观察窗关闭)之后没有别的东西会叫醒
|
/// 为什么无条件续排:忙碌结束(游戏退出、toast 过期、观察窗关闭)之后没有别的东西会叫醒
|
||||||
@@ -256,13 +282,52 @@ impl App {
|
|||||||
heartbeat()
|
heartbeat()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 一帧画完:①够 33 ms 就推进一拍 presenter ②截图模式在第 [`CAPTURE_AT_FRAME`] 帧出图。
|
/// 出图取证:让这一次进程真的发生一次「换页」或「列表 → 详情」(见 [`boot_env::MotionFixture`])。
|
||||||
|
fn motion_fixture(&mut self) {
|
||||||
|
use boot_env::MotionFixture;
|
||||||
|
let Some(fixture) = boot_env::env_motion_fixture() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let route = match (fixture, self.frames) {
|
||||||
|
// 列表 → 详情:第 1 帧先回列表(详情收起来),第 2 帧再点进第一条实例。
|
||||||
|
(MotionFixture::Detail, 1) => Route::Instances,
|
||||||
|
(MotionFixture::Detail, 2) => {
|
||||||
|
let Some(first) = self
|
||||||
|
.state
|
||||||
|
.tiles()
|
||||||
|
.first()
|
||||||
|
.map(|cell| cell.instance.id.clone())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
Route::Detail(first)
|
||||||
|
}
|
||||||
|
(MotionFixture::Route(route), 1) => route,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
diag!("[motion] ULL_MOTION_FIXTURE → {route:?}");
|
||||||
|
self.state.navigate(route);
|
||||||
|
self.view_dirty = true;
|
||||||
|
self.refresh_view();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一帧画完:①动效时间轴推进 ②够 33 ms 就推进一拍 presenter ③截图模式在第
|
||||||
|
/// [`CAPTURE_AT_FRAME`] 帧出图。
|
||||||
///
|
///
|
||||||
/// 时间闸门按**真实流逝时间**判(不是数帧):掉帧不会把游戏启动进度拖慢,
|
/// 时间闸门按**真实流逝时间**判(不是数帧):掉帧不会把游戏启动进度拖慢,
|
||||||
/// 也不会在慢机器上把 1.5 s 的下载阶段拉成 4 s。
|
/// 也不会在慢机器上把 1.5 s 的下载阶段拉成 4 s。
|
||||||
fn on_frame(&mut self) -> Task<Message> {
|
fn on_frame(&mut self) -> Task<Message> {
|
||||||
self.frames += 1;
|
self.frames += 1;
|
||||||
|
|
||||||
|
// M192:动效时间轴。**每帧都调**,帧率上限在帧泵内部(16 ms 一次结算),所以忙碌态的
|
||||||
|
// 近千 fps 不会变成近千次重排;动画结束后帧泵自动清空,下一拍订阅表就不再有 `frames()`。
|
||||||
|
motion::advance();
|
||||||
|
// 出图取证(`ULL_MOTION_FIXTURE`):前两帧各做一次**真的**状态变化。
|
||||||
|
// 没有这步,截图通道每一帧画的都是同一屏,页面切换 / 列表↔详情的入场就没有可拍的相位。
|
||||||
|
if self.frames <= 2 {
|
||||||
|
self.motion_fixture();
|
||||||
|
}
|
||||||
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let elapsed = now.saturating_duration_since(self.last_tick);
|
let elapsed = now.saturating_duration_since(self.last_tick);
|
||||||
if elapsed >= TICK {
|
if elapsed >= TICK {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
//! 启动期的取证环境变量解析(`ULL_START_ROUTE`)。
|
//! 启动期的取证环境变量解析(`ULL_START_ROUTE` / 动效三个开关)。
|
||||||
//!
|
//!
|
||||||
//! 从 `app/update.rs` 原样搬出(那个文件在 M105/M106/M109/M113 之后贴着 400 行硬线):
|
//! 从 `app/update.rs` 原样搬出(那个文件在 M105/M106/M109/M113 之后贴着 400 行硬线):
|
||||||
//! **纯函数、无状态**,只是取值表长到了七条。
|
//! **纯函数、无状态**,只是取值表长到了十条。M192 在这里加的是动效那一组
|
||||||
|
//! ([`motion_scale`] / [`env_motion_pin`] / [`env_motion_fixture`]):档位策略只有这一处,
|
||||||
|
//! 帧泵自己不读环境变量。
|
||||||
|
|
||||||
|
use unlucky_design::motion::MotionScale;
|
||||||
use unlucky_presenter::router::Route;
|
use unlucky_presenter::router::Route;
|
||||||
|
|
||||||
/// 取证开关 `ULL_START_ROUTE` 的取值解析(`settings` / `instances` / `download` / `content` / `logs`)。
|
/// 取证开关 `ULL_START_ROUTE` 的取值解析(`settings` / `instances` / `download` / `content` / `logs`)。
|
||||||
@@ -25,6 +28,84 @@ pub(super) fn env_start_route() -> Option<Route> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 动效档位与相位钉住(M192):帧泵**只在产品的 `on_boot` 里定档**。
|
||||||
|
///
|
||||||
|
/// ## 为什么档位在这里算
|
||||||
|
///
|
||||||
|
/// 三个输入只有启动这一拍齐全:①是不是截图通道(`--screenshot`)②实际渲染后端
|
||||||
|
/// (`backend::effective_kind()`,不是 `--renderer` 的请求值)③`ULL_MOTION` 的强制值。
|
||||||
|
/// 帧泵自己(`components::motion`)不读环境变量,它只接受一个 [`MotionScale`]——这样动效策略
|
||||||
|
/// 只有一处,测试也不必摆环境变量。
|
||||||
|
///
|
||||||
|
/// | 条件 | 档位 | 为什么 |
|
||||||
|
/// |---|---|---|
|
||||||
|
/// | `ULL_MOTION=off` | `NONE` | 一键关(排障 / 想量基线时用) |
|
||||||
|
/// | 截图通道,且没给 `ULL_MOTION_AT` | `NONE` | D30:出图不许有动画(否则第 3 帧抓到中间态,图就不可复现) |
|
||||||
|
/// | CPU 渲染(`tiny-skia`) | `HALF` | 时长减半 = 帧数减半。位移是**布局量**,CPU 档最贵的就是逐帧重排 |
|
||||||
|
/// | 其余(`wgpu`) | `STANDARD` | —— |
|
||||||
|
/// | `ULL_MOTION=half` / `full` | 强制 | 覆盖上表(出图矩阵要在 GPU 上量同一段动效) |
|
||||||
|
///
|
||||||
|
/// 「减少动效」的用户开关**还不存在**(`core/src/setting` 里没有 animation key,见报告 follow-up),
|
||||||
|
/// 所以本函数只认上面这些来源。
|
||||||
|
pub(super) fn motion_scale(screenshot: bool, gpu: bool, pinned: bool) -> MotionScale {
|
||||||
|
match std::env::var("ULL_MOTION").ok().as_deref().map(str::trim) {
|
||||||
|
Some("off") | Some("none") => return MotionScale::NONE,
|
||||||
|
Some("half") => return MotionScale::HALF,
|
||||||
|
Some("full") => return MotionScale::STANDARD,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if pinned {
|
||||||
|
// 取证相位优先于「截图必须静」,否则多帧序列图一张都出不来。
|
||||||
|
return MotionScale::STANDARD;
|
||||||
|
}
|
||||||
|
if screenshot {
|
||||||
|
return MotionScale::NONE;
|
||||||
|
}
|
||||||
|
if gpu {
|
||||||
|
MotionScale::STANDARD
|
||||||
|
} else {
|
||||||
|
MotionScale::HALF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ULL_MOTION_AT=<ms>`:把动画时钟钉在某一毫秒(多帧序列图取证)。
|
||||||
|
///
|
||||||
|
/// 语义与 `ULL_APPEARANCE_*` / `ULL_DIALOG_FIXTURE` 一致:只影响这一次进程。没有它,第 3 帧落在
|
||||||
|
/// 动画的哪个相位取决于这台机器的渲染速度 —— 那样「0% / 50% / 100% 三张图」是抓阄抓出来的。
|
||||||
|
pub(super) fn env_motion_pin() -> Option<f64> {
|
||||||
|
std::env::var("ULL_MOTION_AT")
|
||||||
|
.ok()?
|
||||||
|
.trim()
|
||||||
|
.parse::<f64>()
|
||||||
|
.ok()
|
||||||
|
.filter(|ms| ms.is_finite() && *ms >= 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ULL_MOTION_FIXTURE`:第 1 帧之后**真的**做一次状态变化(多帧序列图靠它)。
|
||||||
|
///
|
||||||
|
/// 为什么要制造变化:页面主体的入场只在「内容标识变了」时起跑(`motion::enter`),而截图通道
|
||||||
|
/// 从第一帧起画的就是同一页——不制造一次真实的路由变化,就没有可拍的动效。
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(super) enum MotionFixture {
|
||||||
|
/// 跳到一个一级页(走「换页」那一档)。
|
||||||
|
Route(Route),
|
||||||
|
/// 「列表 → 详情」:先回列表(第 1 帧),再点进第一条实例(第 2 帧)。
|
||||||
|
Detail,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ULL_MOTION_FIXTURE=<instances|download|logs|settings|content|detail>`。
|
||||||
|
pub(super) fn env_motion_fixture() -> Option<MotionFixture> {
|
||||||
|
Some(match std::env::var("ULL_MOTION_FIXTURE").ok()?.trim() {
|
||||||
|
"instances" => MotionFixture::Route(Route::Instances),
|
||||||
|
"download" => MotionFixture::Route(Route::Download),
|
||||||
|
"logs" => MotionFixture::Route(Route::Logs),
|
||||||
|
"settings" => MotionFixture::Route(Route::Settings),
|
||||||
|
"content" => MotionFixture::Route(Route::Content),
|
||||||
|
"detail" => MotionFixture::Detail,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// 把启动那一拍的**事实**写进启动器自己的日志(M187 / r1 复审 P2-4)。
|
/// 把启动那一拍的**事实**写进启动器自己的日志(M187 / r1 复审 P2-4)。
|
||||||
///
|
///
|
||||||
/// ## 为什么这一拍只能「先记、后落」
|
/// ## 为什么这一拍只能「先记、后落」
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ impl App {
|
|||||||
// 两种时钟都用同一把尺子,所以 trace 的密度本身就是「时钟在跑」的证据。
|
// 两种时钟都用同一把尺子,所以 trace 的密度本身就是「时钟在跑」的证据。
|
||||||
if bucket_after != bucket_before && std::env::var_os("ULL_TICK_TRACE").is_some() {
|
if bucket_after != bucket_before && std::env::var_os("ULL_TICK_TRACE").is_some() {
|
||||||
diag!(
|
diag!(
|
||||||
"[tick] n={} frames={} refreshes={} clock={}",
|
"[tick] n={} frames={} refreshes={} clock={} motion={}",
|
||||||
self.ticks,
|
self.ticks,
|
||||||
self.frames,
|
self.frames,
|
||||||
self.refreshes,
|
self.refreshes,
|
||||||
@@ -102,7 +102,10 @@ impl App {
|
|||||||
"frames"
|
"frames"
|
||||||
} else {
|
} else {
|
||||||
"idle-1hz"
|
"idle-1hz"
|
||||||
}
|
},
|
||||||
|
// M192:动效时间轴已结算的帧数(每秒一行 ⇒ 这一列的增长速度就是动画的实际帧率,
|
||||||
|
// 也是「动画期间重绘成本」的记账口径,见报告)
|
||||||
|
crate::app::components::motion::samples()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
//! buttons/ ← COMPBASE(12b §1 按钮家族 + §2 IconButton)
|
//! buttons/ ← COMPBASE(12b §1 按钮家族 + §2 IconButton)
|
||||||
//! divider.rs ← COMPBASE(15 §22)
|
//! divider.rs ← COMPBASE(15 §22)
|
||||||
//! scroll.rs ← M173:竖直滚动区的滚动条槽(轨道不压内容)
|
//! scroll.rs ← M173:竖直滚动区的滚动条槽(轨道不压内容)
|
||||||
|
//! motion/ ← M192:动效帧泵(时间轴,不是控件家族;页面经 `pages::render` 用它)
|
||||||
//! selection/ ← 本文件只放桩;后续 mission 在自己的目录里填(§18/§19/§10/§13)
|
//! selection/ ← 本文件只放桩;后续 mission 在自己的目录里填(§18/§19/§10/§13)
|
||||||
//! inputs/ ← 同上(§9/§20/§11/§8)
|
//! inputs/ ← 同上(§9/§20/§11/§8)
|
||||||
//! feedback/ ← 同上(§12/§14/§7/§15)
|
//! feedback/ ← 同上(§12/§14/§7/§15)
|
||||||
@@ -52,6 +53,8 @@ pub mod gallery;
|
|||||||
pub mod inputs;
|
pub mod inputs;
|
||||||
pub mod interaction;
|
pub mod interaction;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
|
/// M192:动效帧泵(时间轴,不是控件家族)。页面只经 `pages::render` 用它,见模块文档。
|
||||||
|
pub mod motion;
|
||||||
pub mod navigation;
|
pub mod navigation;
|
||||||
pub mod scroll;
|
pub mod scroll;
|
||||||
pub mod selection;
|
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(), "钉住的相位不会自然结束(取证专用)");
|
||||||
|
}
|
||||||
@@ -143,6 +143,7 @@ use iced::{Element, Length};
|
|||||||
use unlucky_design::elevation::MaterialLayer;
|
use unlucky_design::elevation::MaterialLayer;
|
||||||
use unlucky_design::spacing::Space;
|
use unlucky_design::spacing::Space;
|
||||||
|
|
||||||
|
use crate::app::components::motion;
|
||||||
use crate::theme::Ui;
|
use crate::theme::Ui;
|
||||||
|
|
||||||
use frame::filled;
|
use frame::filled;
|
||||||
@@ -243,14 +244,36 @@ fn render<'a>(
|
|||||||
// M159:自更新提示条挂在**每一页**的页头之上(有新版 / 正在下载 / 已就位时才出现)。
|
// M159:自更新提示条挂在**每一页**的页头之上(有新版 / 正在下载 / 已就位时才出现)。
|
||||||
// 为什么在这里而不是外壳里:外壳(`shell`)只认窗口装饰与导航,页面层才能按快照决定画什么;
|
// 为什么在这里而不是外壳里:外壳(`shell`)只认窗口装饰与导航,页面层才能按快照决定画什么;
|
||||||
// 而「每一页都要能看见」这条要求又决定了它不能只挂在实例页上。
|
// 而「每一页都要能看见」这条要求又决定了它不能只挂在实例页上。
|
||||||
match update::banner(ui, &model.update, model.lang.catalog()) {
|
let tree: Element<'a, Message> = match update::banner(ui, &model.update, model.lang.catalog()) {
|
||||||
Some(banner) => column![banner, page]
|
Some(banner) => column![banner, page]
|
||||||
.spacing(ui.space(Space::X3))
|
.spacing(ui.space(Space::X3))
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.height(Length::Fill)
|
.height(Length::Fill)
|
||||||
.into(),
|
.into(),
|
||||||
None => page,
|
None => page,
|
||||||
}
|
};
|
||||||
|
|
||||||
|
// M192:主体入场(首屏 / 换页 / 列表↔详情)。位移由帧泵按令牌表给,**静息恒为 `(0, 0)`**
|
||||||
|
// 且此时 `motion::shifted` 原样返回,所以关闭动效与「末帧」两条路径都与没有动效时逐像素相同。
|
||||||
|
let shift = motion::enter(screen_key(model.screen), detail_key(model));
|
||||||
|
motion::shifted(tree, shift)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 屏的稳定标识:值变了才跑「换页」那一档([`Screen::ALL`] 的下标 + 1,`0` 留给「还没见过」)。
|
||||||
|
fn screen_key(screen: Screen) -> u64 {
|
||||||
|
Screen::ALL
|
||||||
|
.iter()
|
||||||
|
.position(|candidate| *candidate == screen)
|
||||||
|
.map_or(0, |index| index as u64 + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 详情页的稳定标识:实例 id(`None` = 没选中任何一行)。同一个 id 重复绘制不会重启动画。
|
||||||
|
fn detail_key(model: &Model) -> Option<&str> {
|
||||||
|
model
|
||||||
|
.instances
|
||||||
|
.detail
|
||||||
|
.as_ref()
|
||||||
|
.map(|detail| detail.id.as_str())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 整窗(**harness 专用**):标题栏留白带 + 导航轨 + 当前路由的页面。
|
/// 整窗(**harness 专用**):标题栏留白带 + 导航轨 + 当前路由的页面。
|
||||||
|
|||||||
Reference in new issue
Block a user