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
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user