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:
AskaEth committed 2026-11-12 10:50:02 +08:00
1 parent 5a182e80de
commit b1c09c4c91
21 files changed
+2168 -658

No files matched your search

@@ -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(), "钉住的相位不会自然结束(取证专用)");
}