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:
AskaEth committed 2026-11-09 03:56:19 +08:00
1 parent 393d6f188f
commit a1876a08c0
11 files changed
+1052 -8

No files matched your search

+10
View File
@@ -11,10 +11,14 @@
//! | 条件 | 为什么需要 33 ms 节拍 |
//! |---|---|
//! | 截图通道 | 第 3 帧要落盘 |
//! | **有动画在跑**(M192) | 动效时间轴要逐帧采样(帧泵自己按 16 ms 结算,见 `components::motion`) |
//! | 有 `Busy` / `Running` 实例 | 启动进度、日志尾随都靠 `backend.tick` 推进 |
//! | 有未过期的 toast | toast 按毫秒倒计时 |
//! | 刚派发过指令(3 s 观察窗) | 真 host 的异步事件要靠节拍抽出来 |
//!
//! 上面五条**共享同一个布尔**:订阅表里 `window::frames()` 只出现一处(写在同一个 `if` 里),
//! 所以「动画」与「观察窗 / Busy」同时成立时也只有一条订阅——不双订阅靠的是结构,不是判重。
//!
//! 空闲态**一条帧消息都不来** ⇒ 窗口不重绘、`refresh_view` 不跑(配合 `view_dirty`)。
//!
//! ## 空闲态的时钟从哪来(M90 r3 / P2-2)
@@ -43,6 +47,7 @@ use std::time::Instant;
use iced::{window, Subscription};
use super::components::motion;
use super::message::Message;
use super::state::App;
@@ -85,6 +90,8 @@ impl App {
/// 现在要不要**时钟**(见模块文档的表)。
pub(super) fn needs_frames(&self) -> bool {
self.has_screenshot()
// M192:只有动画在跑时才多这一条(帧泵清空轨道后立刻为假 ⇒ 退回 1 Hz 心跳)。
|| motion::needs_frames()
|| !self.state.toasts.is_empty()
|| !self.state.running().is_empty()
// M175:Java 扫描进行中(进度 3/7 要动起来,否则用户只看到「正在扫描」卡着)。
@@ -94,3 +101,6 @@ impl App {
.is_some_and(|deadline| Instant::now() < deadline)
}
}
#[cfg(test)]
mod tests;