feat(app-iced): 页面切换按导航深度定向 + 模态缩放淡入(M256 MOTION1)
- transition.rs:唯一一张动效表。同级换页走水平(±12 / 旧页 ∓8)、进深层走纵向
(新页 +16 上浮 / 旧页 -8),进腿 medium2/medium1(300/250),退腿 short4→medium1(更短),
进用 Standard、退用 StandardAccelerate。幅度全部取自 Space 令牌。
- pump.rs:Slot 三互斥槽 → Kind(Boot/Peer{forward}/Push/Pop)+ 两腿轨道;新增模态状态机
(Opening/Open/Closing,展开后不占帧,退场走完交回一次 take_modal_closed)。
- components/motion/zoom.rs:绕中心缩放的自绘控件(renderer.with_transformation,
tiny-skia 与 wgpu 都实现了,文字也真的跟着缩放);scale==1.0 时不套变换 ⇒ 出图基线不变。
- pages/mod.rs:page_with_settings_transition(ui, current, previous, …) 叠
stack![opaque(旧页@退场位移), 新页@进场位移];旧函数只剩一句委托(blast radius 不变)。
- App.previous_view:换页时用 std::mem::replace **移动**旧模型(冻结快照),过渡结束当拍
置 None(不随换页次数增长)。
- 模态:逻辑状态当拍关(state.dialogs 立刻空),画面多留 150ms;退场期间弹窗消息被闸门吞掉。
- platform/motion.rs:系统「减少动效」三态探针(Windows=SystemParametersInfoW,
Linux/macOS=子进程+300ms 超时),OnceLock 缓存;Unknown **不降级**(探针失败不静默关动效)。
- MotionScale::HALF 语义核清并写进 design 文档:是**时长减半**(更快),不是半速。
This commit is contained in:
1 parent
5a182e80de
commit
b1c09c4c91
21 files changed
+2168
-658
No files matched your search
@@ -1,65 +1,69 @@
|
||||
//! **动效帧泵**(M192):design 的 motion 令牌 → iced 的**有界**帧订阅。
|
||||
//! **动效帧泵**(M192 建,M256 接上页面方向与模态):design 的 motion 令牌 → iced 的**有界**帧订阅。
|
||||
//!
|
||||
//! ## 这个模块解决什么
|
||||
//!
|
||||
//! `crates/design/src/motion/` 里令牌一直齐着(16 档时长 / 可采样缓动 / 二维弹簧表 /
|
||||
//! `MotionScale::NONE` 降级),但适配层从没消费过 ⇒ 所有状态变化都是硬切。本模块是那条缺失的
|
||||
//! 中间层:**谁能动**([`Slot`])、**动多久**([`spec`] 的令牌表)、**什么时候该订阅帧**
|
||||
//! ([`needs_frames`])。
|
||||
//! `MotionScale::NONE` 降级),但适配层长期只有一个消费点。本模块是那条中间层:
|
||||
//! **谁在动**(页面轨道 / 模态轨道)、**动多久朝哪动**([`pump`] 与 `transition` 的表)、
|
||||
//! **什么时候该订阅帧**([`needs_frames`])。
|
||||
//!
|
||||
//! ## 有界泵的契约(四条,都有守卫)
|
||||
//! ## 有界泵的契约(五条,都有守卫)
|
||||
//!
|
||||
//! | 契约 | 为什么 | 守在哪 |
|
||||
//! |---|---|---|
|
||||
//! | 只在**有轨道**时订阅帧 | `window::frames()` 一订阅就自持重绘循环(实测几百帧/秒) | `pump::tests::a_finished_animation_stops_asking_for_frames` |
|
||||
//! | 轨道到期**当场删除**,不靠「下次状态变化」 | 删晚了就是空闲烧帧 | 同上(把 `advance` 的清理删掉就红) |
|
||||
//! | **时间轴推进**不密于 [`FRAME_MS`](16 ms ≈ 62.5/s),且单次最多吃 [`pump::MAX_STEP_MS`] | 帧到得再快也只该改这么多次布局;空闲醒来时 stale 的帧基准不许把动画一帧杀完 | `pump::tests::frames_are_capped_at_sixty_per_second`、`pump::tests::a_stale_baseline_cannot_kill_the_track_in_one_sample` |
|
||||
//! | 起跑**必须发生在 `update` 侧**([`observe`]),`view` 侧只采样([`sample`]) | iced 先算订阅表(`update()` 内部)后建视图 ⇒ 在 `view` 里插轨道当拍订阅不上,动画会退化成「一帧偏移 + 闪跳」 | `app::subscription::tests::a_state_change_opens_the_frame_subscription_in_the_same_tick` |
|
||||
//!
|
||||
//! ⚠️ **16 ms 只卡「采样与布局推进」,不卡整窗重绘。** 整窗重绘**跟随渲染器**:
|
||||
//! 软件渲染实测 ≈**970 帧/秒**(一次 300 ms 的换页 ≈ **290 次整窗重绘** ≈ **0.30 核·秒**),
|
||||
//! GPU 档被 AutoVsync 限到 40~60 帧/秒。重绘次数是「订阅开着多久」的函数,与闸门无关。
|
||||
//!
|
||||
//! 接法是**加进**既有那条链,不是另起一条:`App::needs_frames()` 多一个「有轨道」的分支,
|
||||
//! 订阅表仍然是**一处** `if`(`app/subscription.rs`),所以与 `watch_until` / Busy 续订/ 心跳
|
||||
//! 天然不会双订阅——它们共享同一个布尔。
|
||||
//! | 只在**有轨道**时订阅帧 | `window::frames()` 一订阅就自持重绘循环(实测几百帧/秒) | `pump/tests.rs`、`app/subscription.rs` |
|
||||
//! | 轨道到期**当场删除**,不靠「下次状态变化」 | 删晚了就是空闲烧帧 | `pump/tests.rs` |
|
||||
//! | **模态完全展开后不占帧** | 对话框开着可能几分钟,动画只有开/关那两段 | `pump/tests.rs::an_open_modal_stops_requesting_frames` |
|
||||
//! | **时间轴推进**不密于 [`pump::FRAME_MS`](16 ms ≈ 62.5/s),且单次最多吃 [`pump::MAX_STEP_MS`] | 帧到得再快也只该改这么多次布局;空闲醒来时 stale 的帧基准不许把动画一帧杀完 | `pump/tests.rs` |
|
||||
//! | 起跑**必须发生在 `update` 侧**([`observe`] / [`observe_modal`]),`view` 侧只采样 | iced 先算订阅表(`update()` 内部)后建视图 ⇒ 在 `view` 里插轨道当拍订阅不上 | `app::subscription::tests` |
|
||||
//!
|
||||
//! ## `MotionScale::NONE` 是**真归零**
|
||||
//!
|
||||
//! 关掉动效 ≠ 跑一遍 0 时长动画:`Pump::play` 在 `is_off()` 时**不插入轨道**,`progress` 于是
|
||||
//! 直接返回 `1.0`(到位),`needs_frames()` 恒为假 ⇒ **一帧都不订阅**。守卫见
|
||||
//! `pump::tests::none_scale_never_starts_a_track`。
|
||||
//! 关掉动效 ≠ 跑一遍 0 时长动画:`Pump::play_page` 在 `is_off()` 时**不插入轨道**,
|
||||
//! [`frame`] 于是返回 [`PageFrame::STILL`](位移**严格** `(0,0)`、不画旧页),[`modal`] 要么
|
||||
//! 不画要么 `progress = 1.0`(缩放严格 1.0),`needs_frames()` 恒为假 ⇒ **一帧都不订阅**。
|
||||
//!
|
||||
//! ## 系统「减少动效」(M256 接的真入口)
|
||||
//!
|
||||
//! [`configure`] 收的是一个**已经算好**的 `MotionScale`(来源在 `app/update/boot_env.rs`:
|
||||
//! 截图 / CPU / env)。系统偏好是**上限**,在这里与它合成:
|
||||
//!
|
||||
//! | 系统偏好 | 结果 | 为什么 |
|
||||
//! |---|---|---|
|
||||
//! | [`crate::platform::motion::ReduceMotion::Reduced`] | `NONE` | 用户明确要求减少动效,优先于默认档 |
|
||||
//! | `AnimationsOn` | 传入的档 | —— |
|
||||
//! | [`crate::platform::motion::ReduceMotion::Unknown`] | 传入的档 | **不许**因为探针失败就静默关掉动效(那会变成「某些机器上动效神秘消失」) |
|
||||
//!
|
||||
//! 探针本身**只在启动时读一次并缓存**(`OnceLock`),不在帧路径上。
|
||||
//!
|
||||
//! ## 为什么是 thread-local 而不是 `App` 的字段
|
||||
//!
|
||||
//! 它是**时间轴**(`(槽 → 进度)` 一张表 + 一个时钟),不是应用状态:没有任何业务语义,也不需要
|
||||
//! 跨进程持久。iced 在事件循环线程上串行调 `update` / `view`(这与 `App::update(&mut self)` 能存在
|
||||
//! 是同一条纪律),所以一个线程局部就够,且**测试天然隔离**(`cargo test` 每个用例一个线程,
|
||||
//! 不会出现「另一条线的动画把 `needs_frames()` 顶成真」的假红)。
|
||||
//! 它是**时间轴**(一张轨道表 + 一个时钟),不是应用状态:没有业务语义、不需要跨进程持久。
|
||||
//! iced 在事件循环线程上串行调 `update` / `view`,所以一个线程局部就够,且**测试天然隔离**。
|
||||
//! 仓内已有同样的先例(`pages/appearance/probe.rs`、`core/src/addon/pack/format/probe.rs`)。
|
||||
//!
|
||||
//! 仓内已有同样的先例(`pages/appearance/probe.rs`、`core/src/addon/pack/format/probe.rs` 都用了
|
||||
//! `#[allow(clippy::missing_const_for_thread_local)]`),所以这不是给本 crate 开的新口子。
|
||||
//! ## 位移走 `padding`
|
||||
//!
|
||||
//! ## 谁在什么时候调
|
||||
//!
|
||||
//! | 时机 | 调用 | 在哪 |
|
||||
//! |---|---|---|
|
||||
//! | 每条消息处理完(**订阅表求值之前**) | [`observe`]:内容标识变了就起跑 | `App::sync_motion`(`app/update/motion.rs`) |
|
||||
//! | 每帧画视图 | [`sample`]:只读当前位移 | `pages::render` |
|
||||
//! | 每帧 | [`advance`]:推进时间轴 | `App::on_frame` |
|
||||
//!
|
||||
//! 页面**拿不到**时长/缓动/弹簧参数——那是 [`spec`] 的事。
|
||||
//!
|
||||
//! 位移走 `container` 的 `padding`:动画期间内容比容器少几像素高,末帧归零后与静息**逐像素相同**。
|
||||
//! 不做逐像素 α(D27:CPU 档零运行时半透明)也不做模糊(同上),所以两个渲染后端出图一致。
|
||||
//! 位移套在 `container` 的 `padding` 上:动画期间内容比容器少几像素,末帧归零后与静息**逐像素相同**。
|
||||
//! 不做逐像素 α(D27:CPU 档禁面板半透明)也不做模糊(同上),所以两个渲染后端出图一致。
|
||||
//! 模态的**缩放**走 `renderer.with_transformation`(两个后端都实现,见 [`scaled`])。
|
||||
|
||||
mod pump;
|
||||
mod transition;
|
||||
mod zoom;
|
||||
|
||||
// 本 mission 里这五个只被测试与文档引用(产品路径只经 `observe`/`sample`/`shifted`,拿不到时长/缓动),
|
||||
// 所以显式 `allow` 一下——与 `interaction::Tracker` 那条同一条理由:它们是对外口径的一部分
|
||||
// (`spec` 是唯一一张规格表),不该因为「暂时没人调」就被删掉。
|
||||
// `Pump` / `MAX_STEP_MS` 只被测试与文档引用(产品路径拿不到时长与时间轴),显式放过:
|
||||
// 它们是对外口径的一部分(`FRAME_MS` 被 `app/subscription.rs` 的守卫用)。
|
||||
#[allow(unused_imports)]
|
||||
pub use pump::{offset, spec, Slot, Spec, FRAME_MS};
|
||||
pub use pump::{Pump, FRAME_MS, MAX_STEP_MS};
|
||||
// 规格表与采样形状全部经这里导出:页面只拿得到「这一帧怎么画」,拿不到「用哪一档、多久」。
|
||||
// (`page_spec`/`Leg`/`*Spec` 只被测试与文档引用,产品路径只用 `Kind` 与采样形状。)
|
||||
#[allow(unused_imports)]
|
||||
pub use transition::{
|
||||
enter_offset, exit_offset, modal_scale, modal_spec, page_spec, Kind, Leg, ModalFrame,
|
||||
ModalSpec, PageFrame, PageSpec, MODAL_SCALE_FROM,
|
||||
};
|
||||
pub use zoom::scaled;
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::time::Instant;
|
||||
@@ -69,10 +73,10 @@ use iced::{Element, Length, Padding};
|
||||
|
||||
use unlucky_design::motion::MotionScale;
|
||||
|
||||
use crate::platform::motion::ReduceMotion;
|
||||
|
||||
// clippy 1.99 的 `missing_const_for_thread_local` 对**已经是** `const { … }` 的初始化也报
|
||||
// (换过三种写法都照报,属于误报;复审 r1 已复核并确认,仓内 `pages/appearance/probe.rs` 等
|
||||
// 两处已有同样的 `allow`)。初始化确实已是常量,所以按项显式放过(上在宏外面不算,lint 是对
|
||||
// 展开后的 item 报的)。
|
||||
// (换过三种写法都照报,属于误报;仓内 `pages/appearance/probe.rs` 等两处已有同样的 `allow`)。
|
||||
thread_local! {
|
||||
/// 进程内唯一时间轴(见模块文档「为什么是 thread-local」)。
|
||||
#[allow(clippy::missing_const_for_thread_local)]
|
||||
@@ -83,10 +87,24 @@ thread_local! {
|
||||
}
|
||||
|
||||
/// 定档:`on_boot` 调一次(截图档给 `NONE`,CPU 档给 `HALF`,见 `app/update/boot_env.rs`)。
|
||||
///
|
||||
/// **系统「减少动效」在这里合成**(见模块文档的表):`Reduced` ⇒ `NONE`;`Unknown` ⇒ 不动。
|
||||
pub fn configure(scale: MotionScale, pin: Option<f64>) {
|
||||
let scale = effective_scale(scale, ReduceMotion::probe(), pin.is_some());
|
||||
PUMP.with(|pump| pump.borrow_mut().configure(scale, pin));
|
||||
}
|
||||
|
||||
/// 档位与系统偏好的**合成本身**(纯函数,可断言)。
|
||||
///
|
||||
/// `pinned` = 取证相位钉住(`ULL_MOTION_AT`):多帧序列图要在同一台机器上重复拍到同一相位,
|
||||
/// 所以钉住时**不**被系统偏好降级(否则取证机一旦开了"减少动效"就一张图都拍不出来)。
|
||||
pub fn effective_scale(scale: MotionScale, reduce: ReduceMotion, pinned: bool) -> MotionScale {
|
||||
match reduce {
|
||||
ReduceMotion::Reduced if !pinned => MotionScale::NONE,
|
||||
_ => scale,
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前档位。
|
||||
pub fn scale() -> MotionScale {
|
||||
PUMP.with(|pump| pump.borrow().scale())
|
||||
@@ -105,14 +123,8 @@ pub fn needs_frames() -> bool {
|
||||
/// 页主体的内容**标识**:**每条消息处理完**调一次(`App::sync_motion`),标识变了就此起跑。
|
||||
///
|
||||
/// `screen` 是 `pages::Screen` 的稳定键(`None` = 这一屏不归 pages 画:账户页 / AI 独立页),
|
||||
/// `detail` 是详情页的实例 id。
|
||||
///
|
||||
/// ## 为什么必须在 update 侧(M192 r1 复审 P1-1)
|
||||
///
|
||||
/// iced 0.14 的顺序是「消息 → `update()`(订阅表在**这里**求值)→ `build_user_interfaces`
|
||||
/// (这时才调 `view`)」(iced_winit-0.14.1 `src/lib.rs:1222-1240`)。轨道若在 `view` 里插,
|
||||
/// 同一拍的订阅表看不见它 ⇒ `frames()` 要等下一条消息(最快鼠标 / 最慢 1 Hz 心跳)才打开,
|
||||
/// 而那一帧的 dt 是整段空闲时长 ⇒ 动画一帧走完,用户只看到一次偏移闪跳。
|
||||
/// `detail` 是详情页的实例 id。方向与深度由 [`pump::Pump::observe`] 从这几个标识里判:
|
||||
/// **同级换页走水平、进/出深层走纵向,旧页反向退场**。
|
||||
///
|
||||
/// 起跑时**顺带把帧基准重置到「现在」**:空闲期没有帧,`LAST_FRAME` 是几分钟前的值,
|
||||
/// 不重置的话第一帧仍会按整段空闲时长结算(第二道兜底是 [`pump::MAX_STEP_MS`])。
|
||||
@@ -123,9 +135,52 @@ pub fn observe(screen: Option<u64>, detail: Option<&str>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前入场位移(**只采样、无副作用**):`pages::render` 每帧调一次。
|
||||
pub fn sample() -> (f32, f32) {
|
||||
PUMP.with(|pump| pump.borrow().offsets())
|
||||
/// 当前这一帧的页面位移(**只采样、无副作用**):`pages::render` 每帧调一次。
|
||||
pub fn frame() -> PageFrame {
|
||||
PUMP.with(|pump| pump.borrow().page_frame())
|
||||
}
|
||||
|
||||
/// 弹窗的**逻辑**开关(每帧 update 侧同步一次):`true` = 队列里有框。
|
||||
///
|
||||
/// 返回 `true` = 起跑了进场轨道(调用方要重置帧基准)。
|
||||
pub fn observe_modal(open: bool) -> bool {
|
||||
let started = PUMP.with(|pump| pump.borrow_mut().observe_modal(open));
|
||||
if started {
|
||||
LAST_FRAME.with(|last| last.set(Some(Instant::now())));
|
||||
}
|
||||
started
|
||||
}
|
||||
|
||||
/// 请求关掉弹窗。返回 `true` = **已经关了**(关着动效 / 本来就没开),调用方立刻
|
||||
/// `close_dialog()`;返回 `false` = 退场动画在跑,真正的关闭由 [`take_modal_closed`] 交回。
|
||||
pub fn begin_modal_close() -> bool {
|
||||
let done = PUMP.with(|pump| pump.borrow_mut().begin_modal_close());
|
||||
if !done {
|
||||
LAST_FRAME.with(|last| last.set(Some(Instant::now())));
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
/// 退场是否已走完(取走一次)。**在 `update` 侧调**:拿到 `true` 就把框真正关掉。
|
||||
pub fn take_modal_closed() -> bool {
|
||||
PUMP.with(|pump| pump.borrow_mut().take_modal_closed())
|
||||
}
|
||||
|
||||
/// 退场是否正在跑(`app/update/dialog.rs` 据此吞掉弹窗消息:框已经在缩小,再点一次「删除」
|
||||
/// 不该再生效)。
|
||||
pub fn modal_closing() -> bool {
|
||||
PUMP.with(|pump| pump.borrow().modal_closing())
|
||||
}
|
||||
|
||||
/// 这一帧模态该画成什么样(缩放进度 + 遮罩浓度都用它)。
|
||||
pub fn modal() -> ModalFrame {
|
||||
PUMP.with(|pump| pump.borrow().modal_frame())
|
||||
}
|
||||
|
||||
/// 当前页面轨道是哪一档(`None` = 静息;日志与断言用,产品路径只采样位移)。
|
||||
#[allow(dead_code)]
|
||||
pub fn kind() -> Option<Kind> {
|
||||
PUMP.with(|pump| pump.borrow().kind())
|
||||
}
|
||||
|
||||
/// 推进时间轴(`App::on_frame` 每帧调一次)。
|
||||
@@ -142,7 +197,7 @@ pub fn advance() {
|
||||
});
|
||||
}
|
||||
|
||||
/// 把入场位移套到树上。
|
||||
/// 把位移套到树上。
|
||||
///
|
||||
/// **总是**套这一层(位移为 `(0,0)` 时只是 `padding = 0`):它是树的**根**,而 iced 的
|
||||
/// `Tree::diff` 按 widget tag 判等——根 tag 一换就重建整棵子树。若只在动画期间才套,
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
//! 帧泵的**纯逻辑**:槽 / 规格表 / 时间轴 / 进度采样。
|
||||
//! 帧泵的**纯逻辑**:时间轴 / 页面切换轨道 / 模态轨道 / 采样。
|
||||
//!
|
||||
//! 这一层不认识 iced、不认识线程、不读环境变量——那些都在 [`super`]。分开的理由很实际:
|
||||
//! 「帧间隔上限」「结束后必须清空」这两条守卫是**算术性质**,用本地 `Pump` 直接摆时间轴就能判死,
|
||||
//! 不必起窗口、不必等真实时间(那两条最容易写成「看起来在跑」的假绿)。
|
||||
//!
|
||||
//! ## 三个概念
|
||||
//! ## 两条轨道(M256):页面的两腿 + 模态的进出,各走各的时间轴(互不替代)
|
||||
//!
|
||||
//! | 名字 | 是什么 | 谁定 |
|
||||
//! | 轨道 | 触发 | 结束 |
|
||||
//! |---|---|---|
|
||||
//! | [`Slot`] | 一个**同屏互斥**的动画位(首屏 / 换页 / 列表↔详情) | 本文件 |
|
||||
//! | [`Spec`] | 该位的时长档 + 缓动档,**只能**是 design 令牌 | [`spec`](全项目唯一一张表) |
|
||||
//! | [`Pump`] | 一条时间轴 + 每槽最多一条轨道 | 本文件 |
|
||||
//! | 页面 | 内容标识变了([`Pump::observe`]) | 两腿里长的那条走完 |
|
||||
//! | 模态 | 弹窗开 / 关([`Pump::observe_modal`] / [`Pump::begin_modal_close`]) | 进场转 [`Modal::Open`](**不再要帧**)、退场转 [`Modal::Hidden`] |
|
||||
//!
|
||||
//! ## 为什么规格表在代码里而不是页面里
|
||||
//!
|
||||
//! D31:弹簧/时长令牌是**表**,页面只许按表取、不许自调参数。所以页面拿不到「给我 250ms」这种
|
||||
//! 口子——它只能说「我要换页了」([`Pump::enter`]),用哪一档由 [`spec`] 决定。改观感只改这张表。
|
||||
//! D31:时长/缓动是**表**([`super::transition`]),页面只许按表取——它只能说「我要换页了」。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use unlucky_design::motion::{DurationToken, MotionScale};
|
||||
|
||||
use unlucky_design::motion::{DurationToken, Easing, MotionScale};
|
||||
use unlucky_design::spacing::Space;
|
||||
use super::transition::{
|
||||
enter_offset, exit_offset, modal_spec, page_spec, Kind, ModalFrame, PageFrame, PageSpec,
|
||||
};
|
||||
|
||||
/// 逐帧**推进时间轴**的最短间隔(16 ms ≈ 60 次/秒)。
|
||||
///
|
||||
@@ -30,10 +27,6 @@ use unlucky_design::spacing::Space;
|
||||
/// |---|---|---|
|
||||
/// | 时间轴推进 / 位移采样(本常量) | ≤62.5 次/秒 | 这里 |
|
||||
/// | **整窗重绘** | **跟随渲染器**:软件渲染实测 ≈**970 帧/秒**(一次 300 ms 的换页 ≈**290 次整窗重绘** ≈ **0.30 核·秒**);GPU 档被 AutoVsync 限到 40~60 | `window::frames()` 一订阅,iced_winit 每条消息后都 `request_redraw()`(iced_winit-0.14.1 `src/lib.rs:1222-1240`) |
|
||||
///
|
||||
/// 所以「动画期间最多 60fps」只对**采样**成立;重绘次数是订阅时长的函数,与 16 ms 无关。
|
||||
/// 为什么仍然要 16 ms:每帧重绘已经是既成事实(忙碌态本来就这样),16 ms 卡的是**布局重排**
|
||||
/// 与 `pages::render` 的重建成本;两者的量级差一个数量级以上(见报告的性能记账)。
|
||||
pub const FRAME_MS: f64 = 16.0;
|
||||
|
||||
/// 单次推进的**最大步进**(4 帧 = 64 ms)。
|
||||
@@ -44,92 +37,31 @@ pub const FRAME_MS: f64 = 16.0;
|
||||
/// **任何一次采样最多只吃掉 4 帧的时长**,掉帧/被挂起时宁可把动画放慢,也不许把它一次性抹掉。
|
||||
pub const MAX_STEP_MS: f64 = FRAME_MS * 4.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 {
|
||||
/// 起跑时刻(动画时间轴的坐标,毫秒)。
|
||||
struct PageTrack {
|
||||
kind: Kind,
|
||||
spec: PageSpec,
|
||||
start_ms: f64,
|
||||
spec: Spec,
|
||||
}
|
||||
|
||||
/// 帧泵:一条时间轴 + 每槽一条轨道。
|
||||
/// 模态的状态机。
|
||||
///
|
||||
/// 为什么要状态机而不是一条进度:**弹窗开着的时候不能订阅帧**。`Open` 是一个"没有轨道"的
|
||||
/// 稳态([`Pump::is_animating`] 为假),只有起手与收尾那两段是动画。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum Modal {
|
||||
/// 没有弹窗。
|
||||
Hidden,
|
||||
/// 进场中。
|
||||
Opening { start_ms: f64 },
|
||||
/// 完全展开(稳态,不占帧)。
|
||||
Open,
|
||||
/// 退场中。
|
||||
Closing { start_ms: f64 },
|
||||
}
|
||||
|
||||
/// 帧泵:一条时间轴 + 最多一条页面轨道 + 一个模态状态机。
|
||||
///
|
||||
/// **默认是关的**(`MotionScale::NONE`):产品启动时由 `App::on_boot` 按档位打开,而
|
||||
/// `pages_demo` 这类截图 harness 从不打开——D30 的「同一份代码出同一张图」因此不受动效影响。
|
||||
@@ -140,7 +72,10 @@ pub struct Pump {
|
||||
pin: Option<f64>,
|
||||
now_ms: f64,
|
||||
pending_ms: f64,
|
||||
tracks: BTreeMap<Slot, Track>,
|
||||
page: Option<PageTrack>,
|
||||
modal: Modal,
|
||||
/// 退场走完了、等调用方真正关掉那个框([`Pump::take_modal_closed`] 取走一次)。
|
||||
close_pending: bool,
|
||||
screen: Option<u64>,
|
||||
detail: Option<String>,
|
||||
painted: bool,
|
||||
@@ -155,7 +90,9 @@ impl Pump {
|
||||
pin: None,
|
||||
now_ms: 0.0,
|
||||
pending_ms: 0.0,
|
||||
tracks: BTreeMap::new(),
|
||||
page: None,
|
||||
modal: Modal::Hidden,
|
||||
close_pending: false,
|
||||
screen: None,
|
||||
detail: None,
|
||||
painted: false,
|
||||
@@ -175,9 +112,11 @@ impl Pump {
|
||||
self.screen = None;
|
||||
self.detail = None;
|
||||
self.pending_ms = 0.0;
|
||||
self.modal = Modal::Hidden;
|
||||
self.close_pending = false;
|
||||
if scale.is_off() {
|
||||
// 关掉动效 = 直接落终态:把在跑的轨道清掉,而不是让它跑一遍 0 时长。
|
||||
self.tracks.clear();
|
||||
self.page = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,43 +138,16 @@ impl Pump {
|
||||
|
||||
/// 现在有没有动画在跑(= 要不要订阅帧)。
|
||||
pub fn is_animating(&self) -> bool {
|
||||
!self.tracks.is_empty()
|
||||
self.page.is_some() || matches!(self.modal, Modal::Opening { .. } | Modal::Closing { .. })
|
||||
}
|
||||
|
||||
/// 起跑一个槽(关着动效时什么都不做——**不是**跑一遍 0 时长动画)。返回 `true` = 真的起跑了。
|
||||
///
|
||||
/// 三个槽**互斥**:它们都是「页主体入场」的不同触发点,同时跑会让位移叠加(首屏 + 换页 =
|
||||
/// 28px)。所以起跑新槽就丢掉旧的——语义上也对:新的一次内容变化取代上一次。
|
||||
pub fn play(&mut self, slot: Slot) -> bool {
|
||||
self.tracks.clear();
|
||||
if self.scale.is_off() {
|
||||
return false;
|
||||
/// 轨道起点的口径:钉住相位时恒为 0(进度只取决于钉住值,与"谁在第几帧起跑"无关)。
|
||||
fn start_now(&self) -> f64 {
|
||||
if self.pin.is_some() {
|
||||
0.0
|
||||
} else {
|
||||
self.now_ms
|
||||
}
|
||||
// 钉住相位时轨道的起点固定在 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),
|
||||
},
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// 采样:`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)
|
||||
}
|
||||
|
||||
/// 当前动画时刻(钉住相位时恒为钉住值)。
|
||||
@@ -243,71 +155,193 @@ impl Pump {
|
||||
self.pin.unwrap_or(self.now_ms)
|
||||
}
|
||||
|
||||
/// 页主体的内容**标识**变了就起跑对应槽;**必须在 update 侧调**(见 `App::sync_motion`)。
|
||||
/// 把某一档时长换算成实际毫秒(`MotionScale` 是唯一缩放口)。
|
||||
fn leg_ms(&self, duration: DurationToken) -> f64 {
|
||||
f64::from(self.scale.ms(duration))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 页面切换
|
||||
|
||||
/// 起跑一次页面切换(关着动效时什么都不做——**不是**跑一遍 0 时长动画)。
|
||||
///
|
||||
/// ## 为什么不能放在 `view` 里(M192 r1 复审 P1-1,这是本轮最重要的一行注释)
|
||||
/// 新轨道**取代**上一条:同一时刻只有一次页面切换(否则位移会叠加)。
|
||||
pub fn play_page(&mut self, kind: Kind) -> bool {
|
||||
self.page = None;
|
||||
if self.scale.is_off() {
|
||||
return false;
|
||||
}
|
||||
let spec = page_spec(kind);
|
||||
self.page = Some(PageTrack {
|
||||
kind,
|
||||
spec,
|
||||
start_ms: self.start_now(),
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// 当前页面轨道(测试与日志用;产品路径只看采样出来的位移)。
|
||||
#[allow(dead_code)]
|
||||
pub fn kind(&self) -> Option<Kind> {
|
||||
self.page.map(|track| track.kind)
|
||||
}
|
||||
|
||||
/// 页主体的这一帧采样(**只采样,无副作用**)。
|
||||
///
|
||||
/// iced 0.14 的顺序是「消息 → `update()`(**`program.subscription()` 在这里求值**)→
|
||||
/// `build_user_interfaces`(这时才调 `view`)」(iced_winit-0.14.1 `src/lib.rs:1222-1240`,
|
||||
/// 订阅表在 `update()` 内部的 `lib.rs:1338` 求值)。所以在 `view` 里插轨道,同一拍的订阅表
|
||||
/// **看不见**它,`frames()` 要等下一条消息(最快鼠标、最慢 1 Hz 心跳)才打开——那一段里
|
||||
/// 页面已经带着位移画过一帧,然后就没了:用户看到的是一次偏移闪跳。
|
||||
/// 两条腿各走各的时长:进场读 `enter.duration`,退场读 `exit.duration`(更短)。
|
||||
/// `exiting` 在退场腿走完的那一帧就变假 ⇒ 调用方不再画旧页(不长留一份幽灵树)。
|
||||
pub fn page_frame(&self) -> PageFrame {
|
||||
let Some(track) = &self.page else {
|
||||
return PageFrame::STILL;
|
||||
};
|
||||
let elapsed = self.elapsed_ms() - track.start_ms;
|
||||
let enter_t = leg_fraction(elapsed, self.leg_ms(track.spec.enter.duration));
|
||||
let exit_t = leg_fraction(elapsed, self.leg_ms(track.spec.exit.duration));
|
||||
PageFrame {
|
||||
enter: enter_offset(
|
||||
track.spec.enter_from,
|
||||
track.spec.enter.easing.sample(enter_t),
|
||||
),
|
||||
exit: exit_offset(track.spec.exit_to, track.spec.exit.easing.sample(exit_t)),
|
||||
exiting: exit_t < 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 内容**标识**变了就起跑对应的一档;**必须在 update 侧调**(见 `App::sync_motion`)。
|
||||
///
|
||||
/// 三条判据(顺序即优先级):
|
||||
/// 1. 第一次看到**归 pages 画的屏** → [`Slot::Boot`];
|
||||
/// 2. 屏变了 → [`Slot::Screen`];
|
||||
/// 3. 屏没变但详情变了 → [`Slot::Detail`]。
|
||||
/// 1. 第一次看到**归 pages 画的屏** → [`Kind::Boot`];
|
||||
/// 2. 屏变了 → [`Kind::Peer`],方向由**屏在导航表里的先后**决定(更靠后的屏 = 向右推进);
|
||||
/// 3. 屏没变但详情变了 → 列表→详情 = [`Kind::Push`](深一层)、详情→列表 = [`Kind::Pop`],
|
||||
/// 两个不同的详情之间横跳按同级处理。
|
||||
///
|
||||
/// `screen == None`(账户页 / AI 独立页:它们不走 `pages::render`)**只记标识、不起跑**——
|
||||
/// 否则会白跑 300 ms 的帧却没人采样它;而「从这些页回到某一屏」因为标识从 `None` 变回
|
||||
/// `Some`,仍然会跑换页那一档。
|
||||
/// `screen == None`(账户页 / AI 独立页:它们不走 `pages::render`)**只记标识、不起跑**。
|
||||
///
|
||||
/// 返回 `true` = 真的起跑了新轨道(调用方要重置帧基准,见 [`MAX_STEP_MS`])。
|
||||
pub fn observe(&mut self, screen: Option<u64>, detail: Option<&str>) -> bool {
|
||||
let mut started = false;
|
||||
if !self.painted {
|
||||
let kind = if !self.painted {
|
||||
self.painted = true;
|
||||
if screen.is_some() {
|
||||
started = self.play(Slot::Boot);
|
||||
}
|
||||
screen.map(|_| Kind::Boot)
|
||||
} else if self.screen != screen {
|
||||
if screen.is_some() {
|
||||
started = self.play(Slot::Screen);
|
||||
}
|
||||
} else if self.detail.as_deref() != detail && screen.is_some() {
|
||||
started = self.play(Slot::Detail);
|
||||
}
|
||||
screen.map(|new| match self.screen {
|
||||
Some(old) => Kind::Peer { forward: new > old },
|
||||
// 从「不归 pages 画」的屏回来:没有旧页可退,按首屏那一档的入场方向。
|
||||
None => Kind::Boot,
|
||||
})
|
||||
} else if screen.is_some() && self.detail.as_deref() != detail {
|
||||
Some(match (self.detail.is_some(), detail.is_some()) {
|
||||
(false, true) => Kind::Push,
|
||||
(true, false) => Kind::Pop,
|
||||
// 两个不同的详情之间横跳:深度没变,按同级换内容处理。
|
||||
_ => Kind::Peer { forward: true },
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
self.screen = screen;
|
||||
if self.detail.as_deref() != detail {
|
||||
self.detail = detail.map(str::to_owned);
|
||||
}
|
||||
started
|
||||
kind.is_some_and(|kind| self.play_page(kind))
|
||||
}
|
||||
|
||||
/// 页主体的入场位移(**只采样,无副作用**):`pages::render` 每帧调一次。
|
||||
// ------------------------------------------------------------ 模态
|
||||
|
||||
/// 弹窗的**逻辑**开关(每帧 update 侧同步一次)。
|
||||
///
|
||||
/// 三个槽的剩余量求和后**取整**:整像素位移不会让文字在动画期间发虚;末帧全部 `1.0`
|
||||
/// ⇒ 恒为 `(0, 0)` ⇒ 与静息的布局逐像素相同。
|
||||
pub fn offsets(&self) -> (f32, f32) {
|
||||
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;
|
||||
/// - 开了 → 从 [`Modal::Hidden`] 起跑进场;已经在进/开着就不动。
|
||||
/// - 关了(不是走 [`Pump::begin_modal_close`] 的那条路,即框已经被别处直接关掉)→
|
||||
/// 立刻落 [`Modal::Hidden`](没有框可画,也就没有退场可言)。
|
||||
///
|
||||
/// 返回 `true` = 起跑了进场轨道。
|
||||
pub fn observe_modal(&mut self, open: bool) -> bool {
|
||||
if open {
|
||||
if matches!(self.modal, Modal::Hidden) {
|
||||
if self.scale.is_off() {
|
||||
self.modal = Modal::Open;
|
||||
return false;
|
||||
}
|
||||
self.modal = Modal::Opening {
|
||||
start_ms: self.start_now(),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
(dx.round(), dy.round())
|
||||
if !matches!(self.modal, Modal::Closing { .. }) {
|
||||
self.modal = Modal::Hidden;
|
||||
self.close_pending = false;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 请求关掉弹窗:有动效就起跑退场(返回 `false`,真正的关闭由 [`Pump::take_modal_closed`]
|
||||
/// 交回调用方),关着动效就返回 `true`(**立刻关,不留 0 时长动画**)。
|
||||
pub fn begin_modal_close(&mut self) -> bool {
|
||||
if self.scale.is_off() || matches!(self.modal, Modal::Hidden) {
|
||||
self.modal = Modal::Hidden;
|
||||
self.close_pending = false;
|
||||
return true;
|
||||
}
|
||||
self.modal = Modal::Closing {
|
||||
start_ms: self.start_now(),
|
||||
};
|
||||
false
|
||||
}
|
||||
|
||||
/// 退场是否已经走完(取走一次,之后返回 `false`)。
|
||||
pub fn take_modal_closed(&mut self) -> bool {
|
||||
std::mem::take(&mut self.close_pending)
|
||||
}
|
||||
|
||||
/// 退场是否正在跑(这段时间里的弹窗消息要被吞掉:框已经在缩小,再点一次「删除」不该再生效)。
|
||||
pub fn modal_closing(&self) -> bool {
|
||||
matches!(self.modal, Modal::Closing { .. })
|
||||
}
|
||||
|
||||
/// 这一帧模态该画成什么样。
|
||||
pub fn modal_frame(&self) -> ModalFrame {
|
||||
match self.modal {
|
||||
Modal::Hidden => ModalFrame::HIDDEN,
|
||||
Modal::Open => ModalFrame {
|
||||
visible: true,
|
||||
progress: 1.0,
|
||||
},
|
||||
Modal::Opening { start_ms } => {
|
||||
let spec = modal_spec();
|
||||
let t = leg_fraction(
|
||||
self.elapsed_ms() - start_ms,
|
||||
self.leg_ms(spec.enter.duration),
|
||||
);
|
||||
ModalFrame {
|
||||
visible: true,
|
||||
progress: spec.enter.easing.sample(t),
|
||||
}
|
||||
}
|
||||
Modal::Closing { start_ms } => {
|
||||
let spec = modal_spec();
|
||||
let t = leg_fraction(
|
||||
self.elapsed_ms() - start_ms,
|
||||
self.leg_ms(spec.exit.duration),
|
||||
);
|
||||
ModalFrame {
|
||||
visible: true,
|
||||
// 退场是**反向**走同一条线:1 → 0(与进场共用 `modal_scale` 的映射)。
|
||||
progress: 1.0 - spec.exit.easing.sample(t),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 时间轴
|
||||
|
||||
/// 推进时间轴。返回 `true` = 这一帧真的推进了(进度变了 ⇒ 要重画)。
|
||||
///
|
||||
/// 四步:①不满 [`FRAME_MS`] 就不动(采样上限)②**单次最多吃 [`MAX_STEP_MS`]**(掉帧 /
|
||||
/// 被挂起 / 空闲醒来的第一帧都不许一次性跨过时长)③到期的轨道**当场删除**(这是「空闲不烧帧」
|
||||
/// 的唯一出口:删掉最后一个轨道之后 [`is_animating`](Self::is_animating) 立刻为假,下一拍订阅表
|
||||
/// 里就不再有 `window::frames()`)④钉住相位时时间轴不前进(取证专用)。
|
||||
/// 的唯一出口)④钉住相位时时间轴不前进(取证专用)。
|
||||
pub fn advance(&mut self, dt_ms: f64) -> bool {
|
||||
if self.tracks.is_empty() {
|
||||
if !self.is_animating() {
|
||||
self.pending_ms = 0.0;
|
||||
return false;
|
||||
}
|
||||
@@ -323,22 +357,40 @@ impl Pump {
|
||||
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);
|
||||
}
|
||||
self.expire();
|
||||
true
|
||||
}
|
||||
|
||||
/// 到期轨道当场清掉(③)。
|
||||
fn expire(&mut self) {
|
||||
let now = self.elapsed_ms();
|
||||
if let Some(track) = self.page {
|
||||
let enter = self.leg_ms(track.spec.enter.duration);
|
||||
let exit = self.leg_ms(track.spec.exit.duration);
|
||||
if now - track.start_ms >= enter.max(exit) {
|
||||
self.page = None;
|
||||
}
|
||||
}
|
||||
let spec = modal_spec();
|
||||
match self.modal {
|
||||
Modal::Opening { start_ms } if now - start_ms >= self.leg_ms(spec.enter.duration) => {
|
||||
self.modal = Modal::Open
|
||||
}
|
||||
Modal::Closing { start_ms } if now - start_ms >= self.leg_ms(spec.exit.duration) => {
|
||||
self.modal = Modal::Hidden;
|
||||
self.close_pending = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `elapsed / duration` 夹到 `0..=1`(`duration <= 0` ⇒ 直接到位,不产生 NaN)。
|
||||
fn leg_fraction(elapsed: f64, duration: f64) -> f32 {
|
||||
if duration <= 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
(elapsed / duration).clamp(0.0, 1.0) as f32
|
||||
}
|
||||
|
||||
impl Default for Pump {
|
||||
|
||||
@@ -1,345 +0,0 @@
|
||||
//! 帧泵的守卫(M192)。全部用**本地** `Pump` 摆时间轴:不起窗口、不等真实时间,所以不会假绿。
|
||||
//!
|
||||
//! 接线级的守卫(真消息 / 真订阅表)在 `app/subscription.rs` 的 `mod tests`:这一组只管算术。
|
||||
|
||||
use super::pump::{offset, spec, Pump, Slot, FRAME_MS, MAX_STEP_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();
|
||||
assert!(pump.play(Slot::Screen), "起跑必须成功");
|
||||
assert!(pump.is_animating(), "起跑后必须有轨道(订阅帧的唯一理由)");
|
||||
|
||||
settle(&mut pump);
|
||||
assert!(
|
||||
!pump.is_animating(),
|
||||
"动画结束必须**当场**退订(占着轨道 = 空闲烧帧)"
|
||||
);
|
||||
assert_eq!(pump.progress(Slot::Screen), 1.0, "结束即到位");
|
||||
assert_eq!(pump.offsets(), (0.0, 0.0), "结束即归位");
|
||||
|
||||
// 再推进也不该有任何采样(订阅表里已经没有帧了)
|
||||
let before = pump.samples();
|
||||
for _ in 0..60 {
|
||||
assert!(!pump.advance(FRAME_MS), "没有轨道时推进必须返回 false");
|
||||
}
|
||||
assert_eq!(pump.samples(), before, "空闲态一次采样都不该发生");
|
||||
}
|
||||
|
||||
/// **采样频率上限**:帧到得再快,时间轴也只按 16 ms 结算(≈62.5 次/秒)。
|
||||
///
|
||||
/// 注意这条**不管整窗重绘**:重绘跟随渲染器(见 [`FRAME_MS`] 文档)。
|
||||
/// 抓得住:把 `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()
|
||||
);
|
||||
}
|
||||
|
||||
/// **stale 帧基准不许把轨道一帧杀完**(M192 r1 复审 P1-1 的算术面)。
|
||||
///
|
||||
/// 运行时真出现过:空闲几分钟后第一条消息起跑轨道,第一帧的 dt 是整段空闲时长 ⇒ 立即到期 ⇒
|
||||
/// 动画一帧走完,用户只看到一次偏移闪跳。两道兜底:起跑时重置帧基准(`motion::observe`)与
|
||||
/// 单次最大步进 [`MAX_STEP_MS`];这条守的是第二道。
|
||||
#[test]
|
||||
fn a_stale_baseline_cannot_kill_the_track_in_one_sample() {
|
||||
let mut pump = standard();
|
||||
pump.play(Slot::Screen);
|
||||
|
||||
// 60 秒的「空闲间隔」当 dt
|
||||
assert!(pump.advance(60_000.0));
|
||||
assert!(
|
||||
pump.is_animating(),
|
||||
"一次超长 dt 不许把轨道当场删掉(那就等于没有动画)"
|
||||
);
|
||||
assert!(
|
||||
pump.now_ms() <= MAX_STEP_MS + 1.0e-6,
|
||||
"单次推进最多 {:?} ms,实测 {}",
|
||||
MAX_STEP_MS,
|
||||
pump.now_ms()
|
||||
);
|
||||
assert!(
|
||||
pump.progress(Slot::Screen) < 1.0,
|
||||
"进度不许一步到位(那就是闪跳)"
|
||||
);
|
||||
|
||||
// 正常帧间隔照样把动画跑完(没被抖动卡死)
|
||||
settle(&mut pump);
|
||||
assert!(!pump.is_animating());
|
||||
}
|
||||
|
||||
/// 采样必须单调、且末尾回到 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!(!pump.observe(Some(1), None), "关掉动效时不许起跑");
|
||||
assert_eq!(pump.offsets(), (0.0, 0.0), "关掉动效必须直接落终态");
|
||||
assert!(!pump.is_animating());
|
||||
assert!(!pump.observe(Some(2), None));
|
||||
assert_eq!(pump.offsets(), (0.0, 0.0));
|
||||
assert!(!pump.observe(Some(2), Some("instance-7")));
|
||||
assert_eq!(pump.offsets(), (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);
|
||||
assert_eq!(pump.offsets(), (0.0, 0.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)
|
||||
assert!(pump.observe(Some(1), None), "首帧要跑 Boot 槽");
|
||||
let (dx, dy) = pump.offsets();
|
||||
assert_eq!(dx, 0.0);
|
||||
assert_eq!(dy, offset(Slot::Boot).1, "首屏应有一个向下的入场位移");
|
||||
assert_eq!(
|
||||
pump.progress(Slot::Screen),
|
||||
1.0,
|
||||
"首帧不该顺带起跑 Screen 槽"
|
||||
);
|
||||
settle(&mut pump);
|
||||
assert!(!pump.observe(Some(1), None), "标识没变 ⇒ 不起跑");
|
||||
assert_eq!(pump.offsets(), (0.0, 0.0));
|
||||
|
||||
// 换屏:Screen(纵向)
|
||||
assert!(pump.observe(Some(2), None));
|
||||
let (dx, dy) = pump.offsets();
|
||||
assert_eq!(dx, 0.0);
|
||||
assert_eq!(dy, offset(Slot::Screen).1);
|
||||
assert!(pump.progress(Slot::Screen) < 1.0, "换屏要跑 Screen 槽");
|
||||
settle(&mut pump);
|
||||
|
||||
// 同屏换详情:Detail(横向)
|
||||
assert!(pump.observe(Some(2), Some("instance-7")));
|
||||
let (dx, dy) = pump.offsets();
|
||||
assert_eq!(dx, offset(Slot::Detail).0, "列表↔详情是横向入场");
|
||||
assert_eq!(dy, 0.0);
|
||||
assert!(pump.progress(Slot::Detail) < 1.0);
|
||||
settle(&mut pump);
|
||||
assert!(!pump.observe(Some(2), Some("instance-7")));
|
||||
assert_eq!(pump.offsets(), (0.0, 0.0));
|
||||
}
|
||||
|
||||
/// 不归 pages 画的屏(账户页 / AI 独立页,`screen == None`)**不起跑**;回到某一屏照样跑。
|
||||
#[test]
|
||||
fn screens_rendered_elsewhere_never_start_a_track() {
|
||||
let mut pump = standard();
|
||||
assert!(pump.observe(Some(1), None), "首屏开机跑 Boot");
|
||||
settle(&mut pump);
|
||||
|
||||
assert!(!pump.observe(None, None), "没有 pages 树可动 ⇒ 不许白开帧");
|
||||
assert!(!pump.is_animating());
|
||||
assert_eq!(pump.offsets(), (0.0, 0.0));
|
||||
|
||||
assert!(
|
||||
pump.observe(Some(1), None),
|
||||
"从账户页回到某一屏仍然是换页,要跑"
|
||||
);
|
||||
assert_eq!(pump.offsets().1, offset(Slot::Screen).1);
|
||||
}
|
||||
|
||||
/// 换页会**取代**首屏那一档:位移不叠加(12px,不是 16+12)。
|
||||
#[test]
|
||||
fn a_new_transition_supersedes_the_previous_one() {
|
||||
let mut pump = standard();
|
||||
assert_eq!(
|
||||
pump.offsets().1,
|
||||
0.0,
|
||||
"还没 observe 时没有位移(采样是纯函数)"
|
||||
);
|
||||
pump.observe(Some(1), None);
|
||||
assert_eq!(pump.offsets().1, offset(Slot::Boot).1);
|
||||
|
||||
// 首屏还没跑完就换页:位移必须是 Screen 那一档,而不是两档相加
|
||||
pump.observe(Some(2), None);
|
||||
assert_eq!(
|
||||
pump.offsets().1,
|
||||
offset(Slot::Screen).1,
|
||||
"新的一次内容变化应取代上一次(叠加会让位移变成两档之和)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 同一屏同一详情重复绘制**永远不会**重新起跑(否则每帧都在重启动画 = 永远停不下来)。
|
||||
#[test]
|
||||
fn repeated_paints_never_restart_the_animation() {
|
||||
let mut pump = standard();
|
||||
pump.observe(Some(3), Some("instance-9"));
|
||||
let frames = settle(&mut pump);
|
||||
assert!(frames > 0);
|
||||
|
||||
for _ in 0..30 {
|
||||
assert!(!pump.observe(Some(3), Some("instance-9")));
|
||||
assert_eq!(pump.offsets(), (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(), "钉住的相位不会自然结束(取证专用)");
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
//! 帧泵与过渡表的守卫(M192 建,M256 扩)。全部用**本地** `Pump` 摆时间轴:不起窗口、
|
||||
//! 不等真实时间,所以不会假绿。
|
||||
//!
|
||||
//! 接线级的守卫(真消息 / 真订阅表)在 `app/subscription.rs`:这一组只管算术与表。
|
||||
//!
|
||||
//! 每条用例都写清「抓得住什么 / 抓不住什么」(T27 之后的纪律)。判据是「泵帧数 + 端点 +
|
||||
//! 单调性」,不是像素比对:像素只能证明"某一张图有没有偏移",证明不了"中间有 19 个相位"。
|
||||
//!
|
||||
//! 模态 / 系统偏好 / 降级那一半在 [`modal`]。
|
||||
|
||||
mod modal;
|
||||
mod timeline;
|
||||
|
||||
use super::pump::Pump;
|
||||
use super::transition::{page_spec, Kind, ModalFrame, PageFrame};
|
||||
use super::*;
|
||||
use unlucky_design::motion::MotionScale;
|
||||
|
||||
/// 标准档、没有钉相位的泵(出厂态是 `NONE`,见 [`Pump::new`])。
|
||||
fn standard() -> Pump {
|
||||
let mut pump = Pump::new();
|
||||
pump.configure(MotionScale::STANDARD, None);
|
||||
pump
|
||||
}
|
||||
|
||||
/// 按「每帧 16 ms」推进到没有轨道为止,返回推进了多少帧(上限 1250 帧 = 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
|
||||
}
|
||||
|
||||
/// 一路采样所有中间态(含终点),用完自己 settle。
|
||||
fn samples(pump: &mut Pump) -> Vec<PageFrame> {
|
||||
let mut out = vec![pump.page_frame()];
|
||||
while pump.is_animating() {
|
||||
pump.advance(FRAME_MS);
|
||||
out.push(pump.page_frame());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 规格表
|
||||
|
||||
/// 规格**全部**来自 design 的令牌表,且落在 M3E 的短档(150~300ms)。
|
||||
///
|
||||
/// 抓得住:谁把某档改成 400ms 以上("更显眼"的诱惑),或把幅度改成非 4 的倍数。
|
||||
/// 抓不住:观感好不好——那要截图(D30),不在这条用例的能力范围里。
|
||||
#[test]
|
||||
fn every_transition_is_built_from_registered_tokens() {
|
||||
let kinds = [
|
||||
Kind::Boot,
|
||||
Kind::Peer { forward: true },
|
||||
Kind::Peer { forward: false },
|
||||
Kind::Push,
|
||||
Kind::Pop,
|
||||
];
|
||||
for kind in kinds {
|
||||
let spec = page_spec(kind);
|
||||
for leg in [spec.enter, spec.exit] {
|
||||
assert!(
|
||||
(150..=300).contains(&leg.duration.ms()),
|
||||
"{}: {}ms 不在 MD3E 短档(150~300)",
|
||||
kind.name(),
|
||||
leg.duration.ms()
|
||||
);
|
||||
assert!(leg.easing.sample(0.0).abs() < 1.0e-6);
|
||||
assert!((leg.easing.sample(1.0) - 1.0).abs() < 1.0e-6);
|
||||
}
|
||||
// 进略长于出(M3E:进场要看清,退场只是让位)
|
||||
assert!(
|
||||
spec.enter.duration.ms() > spec.exit.duration.ms(),
|
||||
"{}: 进场必须比退场长(进 {} / 出 {})",
|
||||
kind.name(),
|
||||
spec.enter.duration.ms(),
|
||||
spec.exit.duration.ms()
|
||||
);
|
||||
// 幅度落在 4px 栅格上,且退场幅度小于进场幅度
|
||||
let (inx, iny) = spec.enter_from;
|
||||
let (outx, outy) = spec.exit_to;
|
||||
for value in [inx, iny, outx, outy] {
|
||||
assert_eq!(value % 4.0, 0.0, "{}: {value} 不在 4px 栅格上", kind.name());
|
||||
}
|
||||
assert!(
|
||||
inx.abs().max(iny.abs()) >= outx.abs().max(outy.abs()),
|
||||
"{}: 退场幅度不该大于进场幅度",
|
||||
kind.name()
|
||||
);
|
||||
}
|
||||
// 四档的动作方向钉住(谁改方向,报告与出图都要跟着改)
|
||||
assert_eq!(
|
||||
page_spec(Kind::Peer { forward: true }).enter_from,
|
||||
(12.0, 0.0)
|
||||
);
|
||||
assert_eq!(page_spec(Kind::Peer { forward: true }).exit_to, (-8.0, 0.0));
|
||||
assert_eq!(
|
||||
page_spec(Kind::Peer { forward: false }).enter_from,
|
||||
(-12.0, 0.0)
|
||||
);
|
||||
assert_eq!(page_spec(Kind::Push).enter_from, (0.0, 16.0));
|
||||
assert_eq!(page_spec(Kind::Push).exit_to, (0.0, -8.0));
|
||||
assert_eq!(page_spec(Kind::Pop).enter_from, (0.0, -16.0));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 页面切换
|
||||
|
||||
/// **进入是 12~20 帧、落在端点、中途有严格中间态**(塔给的判据原文)。
|
||||
///
|
||||
/// 抓得住:①把时长改到 300ms 以外(帧数跑出窗口);②把 `enter_offset` 改成直接返回 0
|
||||
/// ("硬跳",中间态消失);③把末帧没归零(`PageFrame::STILL` 的端点断言红)。
|
||||
/// 抓不住:这一帧画出来好不好看(截图的事)。
|
||||
#[test]
|
||||
fn a_page_enters_over_twelve_to_twenty_frames_and_lands_on_the_endpoint() {
|
||||
let mut pump = standard();
|
||||
assert!(pump.observe(Some(2), None), "首次观察要起跑 Boot");
|
||||
let frames = settle(&mut pump);
|
||||
assert!(
|
||||
(12..=20).contains(&frames),
|
||||
"进入应落在 12~20 帧,实测 {frames} 帧"
|
||||
);
|
||||
|
||||
// 重跑一次,逐帧看端点与单调性
|
||||
pump.observe(Some(3), None); // 同级换页:medium2 = 300ms
|
||||
let trace = samples(&mut pump);
|
||||
assert!(trace.len() >= 12, "中间态太少:{}", trace.len());
|
||||
assert_eq!(
|
||||
trace.last().copied(),
|
||||
Some(PageFrame::STILL),
|
||||
"末帧必须归位"
|
||||
);
|
||||
assert_eq!(trace[0].enter, (12.0, 0.0), "首帧必须满幅(向右推进)");
|
||||
assert!(
|
||||
trace
|
||||
.iter()
|
||||
.skip(1)
|
||||
.any(|f| f.enter.0 > 0.0 && f.enter.0 < 12.0),
|
||||
"中途必须存在**严格介于两端之间**的相位"
|
||||
);
|
||||
// 单调收敛到 0(缓动是单调曲线,取整也不会反弹)
|
||||
let mut previous = trace[0].enter.0;
|
||||
for frame in trace.iter().skip(1) {
|
||||
assert!(
|
||||
frame.enter.0 <= previous,
|
||||
"位移必须单调归零:{previous} → {}",
|
||||
frame.enter.0
|
||||
);
|
||||
previous = frame.enter.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// **旧页反向退场**:同一拍里新页从 +12 归零、旧页向 −8 走;退场腿更短,先结束。
|
||||
///
|
||||
/// 抓得住:把 `exit` 位移删掉/写成同向("只挪新页"),或让退场腿和进场腿一样长。
|
||||
/// 抓不住:旧页**内容**是否正确(那是 `pages::page_with_settings_transition` 的职责,
|
||||
/// 归 `app` 层的用例)。
|
||||
#[test]
|
||||
fn the_old_page_moves_the_other_way_and_leaves_first() {
|
||||
let mut pump = standard();
|
||||
pump.observe(Some(2), None);
|
||||
settle(&mut pump);
|
||||
|
||||
assert!(pump.observe(Some(3), None));
|
||||
let first = pump.page_frame();
|
||||
assert_eq!(first.enter, (12.0, 0.0));
|
||||
assert_eq!(first.exit, (0.0, 0.0), "退场起点是 0");
|
||||
assert!(first.exiting, "退场腿刚开始");
|
||||
|
||||
let mut saw_half = false;
|
||||
let mut exit_finished_at = None;
|
||||
let mut frames = 0;
|
||||
while pump.is_animating() {
|
||||
frames += 1;
|
||||
pump.advance(FRAME_MS);
|
||||
let frame = pump.page_frame();
|
||||
if frame.exit.0 < 0.0 && frame.exit.0 > -8.0 {
|
||||
saw_half = true;
|
||||
}
|
||||
if !frame.exiting && exit_finished_at.is_none() {
|
||||
exit_finished_at = Some(frames);
|
||||
}
|
||||
}
|
||||
assert!(saw_half, "旧页必须有严格中间态(不是一步到位)");
|
||||
let exit_frames = exit_finished_at.expect("退场腿必须在进场腿之前结束");
|
||||
assert!(
|
||||
exit_frames < frames,
|
||||
"退场({exit_frames} 帧)必须比进场({frames} 帧)先结束"
|
||||
);
|
||||
}
|
||||
|
||||
/// **方向由导航深度决定**:同级换页走水平、进深层走纵向。
|
||||
///
|
||||
/// 抓得住:把四档动作表合并成一档("都往右下飘"),或把 `forward` 判据反了(回到左边却向右推)。
|
||||
/// 抓不住:导航表本身的顺序对不对(那是 `pages::Screen::ALL` / `screen_key` 的事)。
|
||||
#[test]
|
||||
fn direction_follows_navigation_depth() {
|
||||
let mut pump = standard();
|
||||
pump.observe(Some(1), None);
|
||||
settle(&mut pump);
|
||||
|
||||
// 向右推进(屏键 1 → 3)
|
||||
pump.observe(Some(3), None);
|
||||
assert_eq!(pump.kind(), Some(Kind::Peer { forward: true }));
|
||||
assert_eq!(pump.page_frame().enter, (12.0, 0.0), "同级往右 = 水平位移");
|
||||
settle(&mut pump);
|
||||
|
||||
// 向左退回(3 → 1)
|
||||
pump.observe(Some(1), None);
|
||||
assert_eq!(pump.kind(), Some(Kind::Peer { forward: false }));
|
||||
assert_eq!(pump.page_frame().enter, (-12.0, 0.0), "同级往左 = 镜像");
|
||||
settle(&mut pump);
|
||||
|
||||
// 进深层(同屏:列表 → 详情)
|
||||
pump.observe(Some(1), Some("instance-7"));
|
||||
assert_eq!(pump.kind(), Some(Kind::Push));
|
||||
let push = pump.page_frame();
|
||||
assert_eq!(push.enter, (0.0, 16.0), "进深层 = 纵向(从下方上浮)");
|
||||
assert_eq!(push.exit, (0.0, 0.0));
|
||||
settle(&mut pump);
|
||||
|
||||
// 出深层(详情 → 列表)
|
||||
pump.observe(Some(1), None);
|
||||
assert_eq!(pump.kind(), Some(Kind::Pop));
|
||||
assert_eq!(pump.page_frame().enter, (0.0, -16.0), "出深层必须镜像");
|
||||
}
|
||||
|
||||
/// **`MotionScale::NONE` 下位移与缩放严格为 0**(不是"很小")。
|
||||
///
|
||||
/// 抓得住:把 `play_page` 的 `is_off()` 分支删掉(变成跑一遍 0 时长动画)——那时 `frame()`
|
||||
/// 会短暂给出非 0 的位移、`samples` 会增长、帧订阅次数不再是 0。
|
||||
/// 抓不住:OS 偏好有没有真的读到(那是 `platform::motion` 的用例与真机取证)。
|
||||
#[test]
|
||||
fn none_scale_is_strictly_zero_not_merely_small() {
|
||||
let mut pump = Pump::new(); // 出厂即 NONE
|
||||
assert!(pump.scale().is_off());
|
||||
|
||||
// 首屏 / 换页 / 进退层:三次状态变化都不许留下轨道,位移必须 `== 0.0`(不是 `abs() < eps`)
|
||||
for (screen, detail) in [
|
||||
(Some(1), None),
|
||||
(Some(2), None),
|
||||
(Some(2), Some("instance-7")),
|
||||
(Some(2), None),
|
||||
] {
|
||||
assert!(!pump.observe(screen, detail), "关掉动效时不许起跑");
|
||||
let frame = pump.page_frame();
|
||||
assert_eq!(frame.enter.0, 0.0);
|
||||
assert_eq!(frame.enter.1, 0.0);
|
||||
assert_eq!(frame.exit.0, 0.0);
|
||||
assert_eq!(frame.exit.1, 0.0);
|
||||
assert!(!frame.exiting, "NONE 档不许画旧页");
|
||||
}
|
||||
|
||||
// 模态:进场直接到 1.0(缩放严格 1.0),退场直接关
|
||||
assert!(!pump.observe_modal(true), "NONE 档不起模态轨道");
|
||||
assert_eq!(
|
||||
pump.modal_frame(),
|
||||
ModalFrame {
|
||||
visible: true,
|
||||
progress: 1.0
|
||||
}
|
||||
);
|
||||
assert!((modal_scale(1.0) - 1.0).abs() < 1.0e-6, "缩放严格 1.0");
|
||||
assert!(pump.begin_modal_close(), "NONE 档必须**当场**关,不留退场");
|
||||
assert_eq!(pump.modal_frame(), ModalFrame::HIDDEN);
|
||||
|
||||
assert!(!pump.is_animating());
|
||||
for _ in 0..120 {
|
||||
assert!(!pump.advance(FRAME_MS), "NONE 档没有任何可推进的");
|
||||
}
|
||||
assert_eq!(pump.samples(), 0, "NONE 档的帧采样次数必须是 0");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 触发判据
|
||||
|
||||
/// 重复绘制**永远不会**重新起跑(否则每帧都重启动画 = 永远停不下来)。
|
||||
#[test]
|
||||
fn repeated_paints_never_restart_the_animation() {
|
||||
let mut pump = standard();
|
||||
pump.observe(Some(3), Some("instance-9"));
|
||||
settle(&mut pump);
|
||||
|
||||
for _ in 0..30 {
|
||||
assert!(!pump.observe(Some(3), Some("instance-9")));
|
||||
assert_eq!(pump.page_frame(), PageFrame::STILL);
|
||||
}
|
||||
assert!(!pump.is_animating());
|
||||
}
|
||||
|
||||
/// 不归 pages 画的屏(账户 / AI 独立页)**不起跑**;回到某一屏照样跑。
|
||||
#[test]
|
||||
fn screens_rendered_elsewhere_never_start_a_track() {
|
||||
let mut pump = standard();
|
||||
pump.observe(Some(1), None);
|
||||
settle(&mut pump);
|
||||
|
||||
assert!(!pump.observe(None, None), "没有 pages 树可动 ⇒ 不许白开帧");
|
||||
assert!(!pump.is_animating());
|
||||
|
||||
assert!(pump.observe(Some(1), None), "从账户页回来仍然是换页");
|
||||
assert_eq!(pump.kind(), Some(Kind::Boot), "没有旧页可退 ⇒ 按首屏方向");
|
||||
}
|
||||
|
||||
/// **过渡中途再换页要能重入**(塔的门槛 D):新的进场、旧的立刻被取代,不叠加、不卡死。
|
||||
///
|
||||
/// 抓得住:把「新轨道取代旧轨道」改成 push(位移会叠加成 24px)、或让旧轨道继续占帧
|
||||
/// (帧数会明显超出 12~20 那一档)。
|
||||
/// 抓不住:`App::previous_view` 的丢弃(那条在 `app/update/motion_sync.rs` 的用例里)。
|
||||
#[test]
|
||||
fn a_second_navigation_mid_flight_takes_over_cleanly() {
|
||||
let mut pump = standard();
|
||||
pump.observe(Some(1), None);
|
||||
settle(&mut pump);
|
||||
|
||||
pump.observe(Some(2), None); // 往右推进
|
||||
for _ in 0..4 {
|
||||
pump.advance(FRAME_MS); // 走 3~5 帧就再点一次
|
||||
}
|
||||
let mid = pump.page_frame();
|
||||
assert!(
|
||||
mid.enter.0 < 12.0 && mid.enter.0 > 0.0,
|
||||
"第一次过渡还在半路"
|
||||
);
|
||||
|
||||
pump.observe(Some(1), None); // 立刻退回左边
|
||||
let restarted = pump.page_frame();
|
||||
assert_eq!(restarted.enter, (-12.0, 0.0), "新方向立刻生效(满幅起点)");
|
||||
assert_eq!(
|
||||
restarted.exit,
|
||||
(0.0, 0.0),
|
||||
"旧页从 0 重新开始,不与上一次叠加"
|
||||
);
|
||||
|
||||
let frames = settle(&mut pump);
|
||||
assert!(
|
||||
(12..=20).contains(&frames),
|
||||
"重入之后仍要在 12~20 帧内停下,实测 {frames}"
|
||||
);
|
||||
assert_eq!(pump.page_frame(), PageFrame::STILL);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! 模态 / 系统偏好 / 有界泵的降级守卫(M256)。从 `tests/mod.rs` 拆出来守 400 行硬线。
|
||||
|
||||
use super::super::pump::FRAME_MS;
|
||||
use super::super::transition::{modal_spec, page_spec, Kind, ModalFrame, PageFrame};
|
||||
use super::super::*;
|
||||
use super::{settle, standard};
|
||||
use unlucky_design::motion::{DurationToken, MotionScale};
|
||||
|
||||
/// 模态表:进 200 / 出 150,且起点缩放来自 design 令牌(0.96)。
|
||||
#[test]
|
||||
fn modal_spec_is_short_and_asymmetric() {
|
||||
let spec = modal_spec();
|
||||
assert_eq!(spec.enter.duration, DurationToken::Short4); // 200
|
||||
assert_eq!(spec.exit.duration, DurationToken::Short3); // 150
|
||||
assert!(spec.enter.duration.ms() > spec.exit.duration.ms());
|
||||
assert!((modal_scale(0.0) - unlucky_design::motion::DIALOG_ENTER_SCALE).abs() < 1.0e-6);
|
||||
assert!((modal_scale(1.0) - 1.0).abs() < 1.0e-6);
|
||||
// 单调(不弹跳:M3E 的对话框不许回弹)
|
||||
let mut previous = modal_scale(0.0);
|
||||
for step in 1..=20 {
|
||||
let value = modal_scale(step as f32 / 20.0);
|
||||
assert!(value >= previous, "缩放倒退了:{previous} → {value}");
|
||||
previous = value;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 模态
|
||||
|
||||
/// 模态:进场 12~13 帧(200ms)、展开后**不再要帧**、退场反向且更短、走完交回一次「已关」。
|
||||
///
|
||||
/// 抓得住:①进场时长被改成 >300ms(帧数跑出去);②`Open` 还占着轨道(`is_animating` 为真 ⇒
|
||||
/// 对话框开着就一直在烧帧);③退场方向反了(`progress` 变成递增);④`take_modal_closed`
|
||||
/// 不返回 `true`(框永远关不掉)。
|
||||
#[test]
|
||||
fn a_modal_opens_stops_burning_frames_and_closes_in_reverse() {
|
||||
let mut pump = standard();
|
||||
assert!(pump.observe_modal(true), "开框要起跑进场");
|
||||
assert!(pump.modal_frame().visible);
|
||||
assert!(pump.modal_frame().progress < 0.5, "起手时是收起状态");
|
||||
|
||||
let enter_frames = settle(&mut pump) as usize;
|
||||
assert!(
|
||||
(10..=14).contains(&enter_frames),
|
||||
"进场 200ms 应约 12 帧,实测 {enter_frames}"
|
||||
);
|
||||
assert_eq!(
|
||||
pump.modal_frame(),
|
||||
ModalFrame {
|
||||
visible: true,
|
||||
progress: 1.0
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
!pump.is_animating(),
|
||||
"**展开后不许占帧**(对话框可能开几分钟,那不是动画)"
|
||||
);
|
||||
assert!(!pump.observe_modal(true), "已经开着 ⇒ 不重新起跑");
|
||||
|
||||
// 退场:反向(1 → 0),且比进场短
|
||||
assert!(!pump.begin_modal_close(), "有动效时不许当场关");
|
||||
assert!(pump.modal_closing());
|
||||
let mut trace = vec![pump.modal_frame().progress];
|
||||
while pump.is_animating() {
|
||||
pump.advance(FRAME_MS);
|
||||
trace.push(pump.modal_frame().progress);
|
||||
}
|
||||
let exit_frames = trace.len() - 1;
|
||||
assert!(
|
||||
exit_frames < enter_frames,
|
||||
"退场({exit_frames} 帧)必须短于进场({enter_frames} 帧)"
|
||||
);
|
||||
assert!(
|
||||
trace.windows(2).all(|pair| pair[0] >= pair[1]),
|
||||
"退场进度必须单调下降:{trace:?}"
|
||||
);
|
||||
assert_eq!(pump.modal_frame(), ModalFrame::HIDDEN, "走完即收起");
|
||||
assert!(pump.take_modal_closed(), "必须交回一次「已关」");
|
||||
assert!(
|
||||
!pump.take_modal_closed(),
|
||||
"只交回一次(重复关会把下一个框也关掉)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 弹窗被别处**硬关**(不是走 `begin_modal_close`)时不玩退场:直接收起、不残留「待关」。
|
||||
#[test]
|
||||
fn a_dialog_closed_elsewhere_does_not_leave_a_pending_close() {
|
||||
let mut pump = standard();
|
||||
pump.observe_modal(true);
|
||||
settle(&mut pump);
|
||||
assert!(pump.modal_frame().visible);
|
||||
|
||||
assert!(!pump.observe_modal(false));
|
||||
assert!(!pump.is_animating());
|
||||
assert_eq!(pump.modal_frame(), ModalFrame::HIDDEN);
|
||||
assert!(!pump.take_modal_closed(), "没有退场就没有「待关」");
|
||||
}
|
||||
|
||||
/// 运行期把关掉动效(`configure(NONE)`)要立刻落终态,而不是把动画「缩到 0 时长」。
|
||||
#[test]
|
||||
fn switching_to_none_mid_flight_settles_immediately() {
|
||||
let mut pump = standard();
|
||||
pump.observe(Some(1), None);
|
||||
pump.advance(FRAME_MS);
|
||||
assert!(pump.is_animating());
|
||||
|
||||
pump.configure(MotionScale::NONE, None);
|
||||
assert!(!pump.is_animating(), "关掉动效必须当场清空轨道");
|
||||
assert_eq!(pump.page_frame(), PageFrame::STILL);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 系统偏好合成
|
||||
|
||||
/// 系统「减少动效」三态与档位的合成(塔的硬约束 C):**`Unknown` 什么都不改**。
|
||||
///
|
||||
/// 抓得住:把 `Unknown` 也降级成 `NONE`("探针失败就静默关动效"),或把 `Reduced` 漏了。
|
||||
/// 抓不住:真机注册表/系统设置里的值(那要改系统设置,见报告"没验的部分")。
|
||||
#[test]
|
||||
fn reduced_motion_only_triggers_on_an_explicit_reduced() {
|
||||
use crate::platform::motion::ReduceMotion;
|
||||
|
||||
for scale in [MotionScale::STANDARD, MotionScale::HALF, MotionScale::NONE] {
|
||||
assert_eq!(
|
||||
effective_scale(scale, ReduceMotion::Reduced, false),
|
||||
MotionScale::NONE,
|
||||
"系统明确要求减少动效 ⇒ 归零"
|
||||
);
|
||||
assert_eq!(
|
||||
effective_scale(scale, ReduceMotion::AnimationsOn, false),
|
||||
scale,
|
||||
"系统里动效开着 ⇒ 用原档"
|
||||
);
|
||||
assert_eq!(
|
||||
effective_scale(scale, ReduceMotion::Unknown, false),
|
||||
scale,
|
||||
"**读不到 ≠ 要减少**:探针失败不许静默关掉动效"
|
||||
);
|
||||
assert_eq!(
|
||||
effective_scale(scale, ReduceMotion::Reduced, true),
|
||||
scale,
|
||||
"取证相位钉住时不被系统偏好降级(多帧序列图要可复现)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 取证相位
|
||||
|
||||
/// `ULL_MOTION_AT` 的钉住相位:时间轴不前进,进度恒定(多帧序列图靠它可复现)。
|
||||
#[test]
|
||||
fn pinned_phase_freezes_the_clock() {
|
||||
let mut pump = Pump::new();
|
||||
pump.configure(MotionScale::STANDARD, Some(150.0));
|
||||
pump.observe(Some(1), None); // 300ms 档 ⇒ 钉在 150 = 一半
|
||||
|
||||
for _ in 0..10 {
|
||||
pump.advance(FRAME_MS);
|
||||
}
|
||||
let frame = pump.page_frame();
|
||||
let expected = 16.0 * (1.0 - page_spec(Kind::Boot).enter.easing.sample(0.5));
|
||||
assert!(
|
||||
(frame.enter.1 - expected.round()).abs() < 1.0,
|
||||
"钉在 150/300 的位移应约 {expected},实测 {}",
|
||||
frame.enter.1
|
||||
);
|
||||
assert!(pump.is_animating(), "钉住的相位不会自然结束(取证专用)");
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! 有界泵的算术守卫(M256 从 `tests/mod.rs` 拆出,守 400 行硬线):采样频率上限、到期即退订、
|
||||
//! stale 帧基准不许一帧杀完、以及 `MotionScale::HALF` 的**语义**(时长减半 ≠ 半速)。
|
||||
//!
|
||||
//! 这些全是「本地 `Pump` + 摆时间轴」就能判死的算术性质,所以不需要窗口、不需要真实时间。
|
||||
|
||||
use super::super::pump::{Pump, FRAME_MS, MAX_STEP_MS};
|
||||
use super::super::transition::PageFrame;
|
||||
use super::{settle, standard};
|
||||
use unlucky_design::motion::{DurationToken, MotionScale};
|
||||
|
||||
// ------------------------------------------------------------------ 有界泵
|
||||
|
||||
/// **空闲不烧帧**:动画跑完 ⇒ `is_animating()` 立刻为假,且不再推进。
|
||||
///
|
||||
/// 抓得住:把「到期即删除」删掉(轨道永远留着 ⇒ 帧订阅永不退订)。
|
||||
#[test]
|
||||
fn a_finished_animation_stops_asking_for_frames() {
|
||||
let mut pump = standard();
|
||||
assert!(pump.observe(Some(1), None));
|
||||
assert!(pump.is_animating(), "起跑后必须有轨道(订阅帧的唯一理由)");
|
||||
|
||||
settle(&mut pump);
|
||||
assert!(!pump.is_animating(), "动画结束必须**当场**退订");
|
||||
assert_eq!(pump.page_frame(), PageFrame::STILL);
|
||||
|
||||
let before = pump.samples();
|
||||
for _ in 0..60 {
|
||||
assert!(!pump.advance(FRAME_MS), "没有轨道时推进必须返回 false");
|
||||
}
|
||||
assert_eq!(pump.samples(), before, "空闲态一次采样都不该发生");
|
||||
}
|
||||
|
||||
/// **采样频率上限**:帧到得再快,时间轴也只按 16 ms 结算(≈62.5 次/秒)。
|
||||
#[test]
|
||||
fn frames_are_capped_at_sixty_per_second() {
|
||||
let mut pump = standard();
|
||||
pump.observe(Some(1), None);
|
||||
|
||||
let mut advanced = 0;
|
||||
for _ in 0..200 {
|
||||
if pump.advance(1.0) {
|
||||
advanced += 1;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
(11..=13).contains(&advanced),
|
||||
"200 ms 内应 12±1 次推进,实测 {advanced}"
|
||||
);
|
||||
assert_eq!(pump.samples(), advanced);
|
||||
assert!(
|
||||
(pump.now_ms() - 192.0).abs() <= 16.0,
|
||||
"时间轴应约 192 ms,实测 {}",
|
||||
pump.now_ms()
|
||||
);
|
||||
}
|
||||
|
||||
/// **stale 帧基准不许把轨道一帧杀完**(M192 r1 复审 P1-1 的算术面)。
|
||||
#[test]
|
||||
fn a_stale_baseline_cannot_kill_the_track_in_one_sample() {
|
||||
let mut pump = standard();
|
||||
pump.observe(Some(1), None);
|
||||
|
||||
assert!(pump.advance(60_000.0)); // 60 秒的"空闲间隔"当 dt
|
||||
assert!(pump.is_animating(), "一次超长 dt 不许把轨道当场删掉");
|
||||
assert!(pump.now_ms() <= MAX_STEP_MS + 1.0e-6);
|
||||
assert!(pump.page_frame().enter.1 > 0.0, "进度不许一步到位");
|
||||
|
||||
settle(&mut pump);
|
||||
assert!(!pump.is_animating());
|
||||
}
|
||||
|
||||
/// 半时长档(CPU 渲染兜底)真的把时长减半:同样的推进,轨道更早结束。
|
||||
///
|
||||
/// 顺带钉住 `MotionScale::HALF` 的**语义**(M256 核清项②):是「时长 ×0.5」=更快,
|
||||
/// 不是"半速"。抓得住:把 `HALF.factor` 改成 2.0(半速)或 1.0(无效)。
|
||||
#[test]
|
||||
fn half_scale_halves_the_duration_not_the_speed() {
|
||||
assert_eq!(MotionScale::HALF.factor(), 0.5);
|
||||
assert_eq!(MotionScale::HALF.ms(DurationToken::Medium2), 150.0);
|
||||
|
||||
let mut full = standard();
|
||||
full.observe(Some(1), None);
|
||||
let full_frames = settle(&mut full);
|
||||
|
||||
let mut half = Pump::new();
|
||||
half.configure(MotionScale::HALF, None);
|
||||
half.observe(Some(1), None);
|
||||
let half_frames = settle(&mut half);
|
||||
|
||||
assert!(half_frames > 0 && full_frames > 0);
|
||||
assert!(
|
||||
half_frames * 2 <= full_frames + 2,
|
||||
"HALF 应约用一半帧数:full={full_frames} half={half_frames}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! 动效**规格表**与位移数学(M256):一次页面切换的方向/深度、两腿时长、模态的进出。
|
||||
//!
|
||||
//! 这一层是纯函数:不认识 iced、不认识时钟、不读环境变量。改观感只改这里的一张表。
|
||||
//!
|
||||
//! ## 为什么要有「方向」和「两腿」
|
||||
//!
|
||||
//! M192 只做了一次「新页从下方归位」(三个槽一张表)。M3 Expressive 的页面过渡不是这样:
|
||||
//! 它是 **shared-axis**——同级换页走水平轴、进深层走纵深轴,而且**旧页反向退场**(两腿同时跑)。
|
||||
//! 所以本表对每种切换给出四件东西:进场腿、退场腿、新页起点位移、旧页终点位移。
|
||||
//!
|
||||
//! | 切换 | 轴 | 新页从哪来 | 旧页去哪 | 进场 / 退场 |
|
||||
//! |---|---|---|---|---|
|
||||
//! | [`Kind::Boot`] 首屏 | 纵向 | 下方 `+X4`(16) | —— | `medium2` 300 / —— |
|
||||
//! | [`Kind::Peer`] 同级换页 | 水平 | 右/左 `±X3`(±12) | 反向 `∓X2`(∓8) | `medium2` 300 / `medium1` 250 |
|
||||
//! | [`Kind::Push`] 进深层(列表→详情) | 纵向 | 下方 `+X4`(16) | 上方 `-X2`(-8) | `medium1` 250 / `short4` 200 |
|
||||
//! | [`Kind::Pop`] 出深层(详情→列表) | 纵向 | 上方 `-X4`(-16) | 下方 `+X2`(8) | `medium1` 250 / `short4` 200 |
|
||||
//!
|
||||
//! 三条口径(都有守卫):①**进略长于出**(300>250、250>200)——M3 的进场要看得清,退场只是让位;
|
||||
//! ②幅度全部落在 4px 栅格上(`Space` 令牌,不新造数字),且退场幅度**小于**进场幅度;
|
||||
//! ③进/退曲线不同:进场 `Standard`(屏内元素起止都平滑),退场 `StandardAccelerate`
|
||||
//! (M3 的「离场加速」口径,让旧页干脆地让出注意力)。
|
||||
|
||||
use unlucky_design::motion::{DurationToken, Easing, DIALOG_ENTER_SCALE};
|
||||
use unlucky_design::spacing::Space;
|
||||
|
||||
/// 一次页面切换的方向与深度来源(判据在 `Pump::observe`)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
/// 首屏进入:进程内第一次看到归 pages 画的屏。没有旧页。
|
||||
Boot,
|
||||
/// **同级**换页:新页在导航表里更靠右(`forward`)或更靠左。外屏(账户 / AI)来回也走这一档。
|
||||
Peer {
|
||||
/// `true` = 新页在导航顺序里更靠后(向右推进)。
|
||||
forward: bool,
|
||||
},
|
||||
/// **进深层**:同一屏里从列表进详情(导航深度 +1)。
|
||||
Push,
|
||||
/// **出深层**:详情退回列表(导航深度 -1)。
|
||||
Pop,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
/// 稳定名字(日志 / 断言失败信息用;产品路径不读这一档)。
|
||||
#[allow(dead_code)]
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Kind::Boot => "boot",
|
||||
Kind::Peer { forward: true } => "peer-forward",
|
||||
Kind::Peer { forward: false } => "peer-back",
|
||||
Kind::Push => "push",
|
||||
Kind::Pop => "pop",
|
||||
}
|
||||
}
|
||||
|
||||
/// 这一档有没有旧页参与退场(首屏没有)。
|
||||
#[allow(dead_code)]
|
||||
pub const fn has_old_page(self) -> bool {
|
||||
!matches!(self, Kind::Boot)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条腿:时长档 × 缓动档(两个都是 design 令牌,不是毫秒字面量)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Leg {
|
||||
/// 时长档(经 `MotionScale` 缩放后生效)。
|
||||
pub duration: DurationToken,
|
||||
/// 缓动曲线。
|
||||
pub easing: Easing,
|
||||
}
|
||||
|
||||
/// 一次页面切换的完整规格。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PageSpec {
|
||||
/// 新页那一腿。
|
||||
pub enter: Leg,
|
||||
/// 旧页那一腿(退场)。
|
||||
pub exit: Leg,
|
||||
/// 新页的**起点**位移(终点恒为 `(0, 0)`);正值 = 内容先被推到右下再归位。
|
||||
pub enter_from: (f32, f32),
|
||||
/// 旧页的**终点**位移(起点恒为 `(0, 0)`)。
|
||||
pub exit_to: (f32, f32),
|
||||
}
|
||||
|
||||
/// 切换 → 规格(**唯一**一张页面动效表)。
|
||||
pub const fn page_spec(kind: Kind) -> PageSpec {
|
||||
// 进场腿:屏内整块内容换人,用 Standard(起止都平滑);退场腿用 StandardAccelerate
|
||||
// (M3 的离场加速,旧页干脆让位)。
|
||||
const ENTER: Leg = Leg {
|
||||
duration: DurationToken::Medium1,
|
||||
easing: Easing::Standard,
|
||||
};
|
||||
const EXIT: Leg = Leg {
|
||||
duration: DurationToken::Short4,
|
||||
easing: Easing::StandardAccelerate,
|
||||
};
|
||||
const PEER_ENTER: Leg = Leg {
|
||||
duration: DurationToken::Medium2,
|
||||
easing: Easing::Standard,
|
||||
};
|
||||
const PEER_EXIT: Leg = Leg {
|
||||
duration: DurationToken::Medium1,
|
||||
easing: Easing::StandardAccelerate,
|
||||
};
|
||||
|
||||
// 幅度:只从间距令牌取(4 的倍数),退场幅度**小于**进场幅度(M3:退场不跟进场抢注意力)。
|
||||
let far = Space::X4.dp(); // 16
|
||||
let near = Space::X3.dp(); // 12
|
||||
let short = Space::X2.dp(); // 8
|
||||
|
||||
match kind {
|
||||
Kind::Boot => PageSpec {
|
||||
enter: PEER_ENTER,
|
||||
exit: PEER_EXIT,
|
||||
enter_from: (0.0, far),
|
||||
exit_to: (0.0, 0.0),
|
||||
},
|
||||
Kind::Peer { forward: true } => PageSpec {
|
||||
enter: PEER_ENTER,
|
||||
exit: PEER_EXIT,
|
||||
enter_from: (near, 0.0),
|
||||
exit_to: (-short, 0.0),
|
||||
},
|
||||
Kind::Peer { forward: false } => PageSpec {
|
||||
enter: PEER_ENTER,
|
||||
exit: PEER_EXIT,
|
||||
enter_from: (-near, 0.0),
|
||||
exit_to: (short, 0.0),
|
||||
},
|
||||
Kind::Push => PageSpec {
|
||||
enter: ENTER,
|
||||
exit: EXIT,
|
||||
enter_from: (0.0, far),
|
||||
exit_to: (0.0, -short),
|
||||
},
|
||||
Kind::Pop => PageSpec {
|
||||
enter: ENTER,
|
||||
exit: EXIT,
|
||||
enter_from: (0.0, -far),
|
||||
exit_to: (0.0, short),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 模态的进 / 出两腿。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ModalSpec {
|
||||
/// 进场:强调减速(M3 里"出现在屏上"的主曲线)。
|
||||
pub enter: Leg,
|
||||
/// 退场:强调加速,且**短于**进场。
|
||||
pub exit: Leg,
|
||||
}
|
||||
|
||||
/// 模态规格(唯一来源)。
|
||||
///
|
||||
/// 时长取 `short4`(200ms)/ `short3`(150ms):对话框是「用户刚做了一件事的回应」,
|
||||
/// 属于 M3 的短档;比起页面切换它必须更短,否则会觉得界面在拖。
|
||||
#[allow(clippy::missing_const_for_fn)]
|
||||
pub const fn modal_spec() -> ModalSpec {
|
||||
ModalSpec {
|
||||
enter: Leg {
|
||||
duration: DurationToken::Short4,
|
||||
easing: Easing::EmphasizedDecelerate,
|
||||
},
|
||||
exit: Leg {
|
||||
duration: DurationToken::Short3,
|
||||
easing: Easing::EmphasizedAccelerate,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 一帧页面采样(**只读**:`pages::render` 每帧取一次,不起轨道)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PageFrame {
|
||||
/// 新页位移(终点恒 `(0,0)`)。
|
||||
pub enter: (f32, f32),
|
||||
/// 旧页位移(起点恒 `(0,0)`)。
|
||||
pub exit: (f32, f32),
|
||||
/// 旧页是否还该被画出来(退场腿没走完)。`false` ⇒ 调用方**不要**再画旧页。
|
||||
pub exiting: bool,
|
||||
}
|
||||
|
||||
impl PageFrame {
|
||||
/// 静息:新页在位、没有旧页。`MotionScale::NONE` 下**恒为**这个值(位移严格 0)。
|
||||
pub const STILL: Self = Self {
|
||||
enter: (0.0, 0.0),
|
||||
exit: (0.0, 0.0),
|
||||
exiting: false,
|
||||
};
|
||||
}
|
||||
|
||||
/// 一帧模态采样。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ModalFrame {
|
||||
/// 要不要把这一层画出来(`false` ⇒ 不画遮罩也不画卡片)。
|
||||
pub visible: bool,
|
||||
/// `0` = 刚起手(缩小 + 遮罩最淡),`1` = 完全展开(缩放 1.0 + 遮罩最浓)。
|
||||
pub progress: f32,
|
||||
}
|
||||
|
||||
impl ModalFrame {
|
||||
/// 没有弹窗。
|
||||
pub const HIDDEN: Self = Self {
|
||||
visible: false,
|
||||
progress: 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
/// 模态的起始缩放(design 令牌 `DIALOG_ENTER_SCALE` 的再导出:视图侧只认这一条路径)。
|
||||
pub const MODAL_SCALE_FROM: f32 = DIALOG_ENTER_SCALE;
|
||||
|
||||
/// 模态当前缩放:`progress = 0` 时是 [`MODAL_SCALE_FROM`],`1` 时是 1.0。
|
||||
pub fn modal_scale(progress: f32) -> f32 {
|
||||
let p = progress.clamp(0.0, 1.0);
|
||||
MODAL_SCALE_FROM + (1.0 - MODAL_SCALE_FROM) * p
|
||||
}
|
||||
|
||||
/// 端点固定的线性插值:`progress = 0` → `from`,`progress = 1` → `0`。取整到整像素。
|
||||
///
|
||||
/// 为什么取整:整页位移落在整像素上,动画期间文字不会因亚像素采样而发虚(M192 的同一口径);
|
||||
/// 末帧恒为 `(0, 0)` ⇒ 与静息逐像素相同。
|
||||
pub fn enter_offset(from: (f32, f32), progress: f32) -> (f32, f32) {
|
||||
let remaining = 1.0 - progress.clamp(0.0, 1.0);
|
||||
((from.0 * remaining).round(), (from.1 * remaining).round())
|
||||
}
|
||||
|
||||
/// 从 `(0, 0)` 走到 `to` 的位移(旧页退场)。取整口径同上。
|
||||
pub fn exit_offset(to: (f32, f32), progress: f32) -> (f32, f32) {
|
||||
let done = progress.clamp(0.0, 1.0);
|
||||
((to.0 * done).round(), (to.1 * done).round())
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//! 缩放层(M256):把一棵子树整体缩放(模态卡片进出场用)。
|
||||
//!
|
||||
//! ## 为什么自己写一个
|
||||
//!
|
||||
//! iced 0.14 的控件清单里**没有**通用的 transform / opacity 控件(`iced_widget` 只有 `stack` /
|
||||
//! `opaque` / `float` / `pin`)。但渲染器层是有的:`Renderer::with_transformation` 在**两个后端
|
||||
//! 都实现了**——`iced_tiny_skia-0.14.1/src/lib.rs:211-217` 与 `iced_wgpu-0.14.0/src/lib.rs:682-688`
|
||||
//! 各是一行 `layers.push_transformation(...)`。而且 tiny_skia 对文字也乘了 `transformation`:
|
||||
//! `layer.rs:73-96` 把字号、行高、bounds 都按 `scale_factor()` 缩过 ⇒ **文字是真缩放**,
|
||||
//! 不是"只缩背景、字不动"。
|
||||
//!
|
||||
//! ## 静息态**逐像素不变**
|
||||
//!
|
||||
//! `scale == 1.0` 时直接 `content.draw(..)`,**不套**变换:出图基线(D30)不受这层影响,
|
||||
//! 也不给渲染器多压一层矩阵。
|
||||
//!
|
||||
//! ## 已知局限(登记在报告里)
|
||||
//!
|
||||
//! 命中测试用的是**布局矩形**(未缩放),所以缩放途中的点击落点仍按 1.0 的框算。
|
||||
//! 模态只在开/关那 150~200 ms 里缩放,而那两段本来就不该被点击(`app/update/dialog.rs`
|
||||
//! 在退场期间直接吞掉弹窗消息),所以这件事没有可观察的后果。
|
||||
|
||||
use iced::advanced::layout::{self, Layout};
|
||||
use iced::advanced::renderer;
|
||||
use iced::advanced::widget::{tree, Tree};
|
||||
use iced::advanced::{mouse, Clipboard, Shell, Widget};
|
||||
use iced::{Element, Event, Length, Rectangle, Size, Transformation, Vector};
|
||||
|
||||
/// 把 `content` 缩放 `scale` 倍(**绕自身中心**),布局尺寸不变。
|
||||
pub fn scaled<'a, Message, Theme, Renderer>(
|
||||
content: impl Into<Element<'a, Message, Theme, Renderer>>,
|
||||
scale: f32,
|
||||
) -> Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: 'a,
|
||||
Theme: 'a,
|
||||
Renderer: iced::advanced::Renderer + 'a,
|
||||
{
|
||||
Element::new(Scaled {
|
||||
content: content.into(),
|
||||
scale,
|
||||
})
|
||||
}
|
||||
|
||||
/// 绕**中心**缩放的变换:`T(-c) · S · T(c)`(`Mul` 的乘法序 = 先右后左,见 iced_core 的
|
||||
/// `impl Mul for Transformation`)。
|
||||
fn about_center(bounds: Rectangle, scale: f32) -> Transformation {
|
||||
let (cx, cy) = (
|
||||
bounds.x + bounds.width / 2.0,
|
||||
bounds.y + bounds.height / 2.0,
|
||||
);
|
||||
Transformation::translate(-cx, -cy)
|
||||
* Transformation::scale(scale)
|
||||
* Transformation::translate(cx, cy)
|
||||
}
|
||||
|
||||
struct Scaled<'a, Message, Theme, Renderer> {
|
||||
content: Element<'a, Message, Theme, Renderer>,
|
||||
scale: f32,
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer> Scaled<'_, Message, Theme, Renderer> {
|
||||
/// 这条变换要不要真的套(`1.0` 时什么都不做)。
|
||||
fn transformation(&self, bounds: Rectangle) -> Option<Transformation> {
|
||||
// 0.1% 以下视为到位:亚像素缩放只会让文字发虚,没有观感收益。
|
||||
if (self.scale - 1.0).abs() < 1.0e-3 {
|
||||
return None;
|
||||
}
|
||||
Some(about_center(bounds, self.scale.max(0.01)))
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
|
||||
for Scaled<'_, Message, Theme, Renderer>
|
||||
where
|
||||
Renderer: iced::advanced::Renderer,
|
||||
{
|
||||
fn tag(&self) -> tree::Tag {
|
||||
self.content.as_widget().tag()
|
||||
}
|
||||
|
||||
fn state(&self) -> tree::State {
|
||||
self.content.as_widget().state()
|
||||
}
|
||||
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
self.content.as_widget().children()
|
||||
}
|
||||
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
self.content.as_widget().diff(tree);
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> Size<Length> {
|
||||
self.content.as_widget().size_hint()
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
self.content.as_widget_mut().layout(tree, renderer, limits)
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
// 先算变换(借 layout 的 bounds),再借用 content —— 两个借用互不重叠。
|
||||
let transformation = self.transformation(layout.bounds());
|
||||
let content = self.content.as_widget();
|
||||
|
||||
match transformation {
|
||||
None => content.draw(tree, renderer, theme, style, layout, cursor, viewport),
|
||||
Some(transformation) => renderer.with_transformation(transformation, |renderer| {
|
||||
content.draw(tree, renderer, theme, style, layout, cursor, viewport);
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &Renderer,
|
||||
operation: &mut dyn iced::advanced::widget::Operation,
|
||||
) {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(tree, layout, renderer, operation);
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget_mut().update(
|
||||
tree, event, layout, cursor, renderer, clipboard, shell, viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
self.content
|
||||
.as_widget()
|
||||
.mouse_interaction(tree, layout, cursor, viewport, renderer)
|
||||
}
|
||||
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
tree: &'a mut Tree,
|
||||
layout: Layout<'a>,
|
||||
renderer: &Renderer,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<iced::overlay::Element<'a, Message, Theme, Renderer>> {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.overlay(tree, layout, renderer, viewport, translation)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user