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,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())
}