From a1876a08c082f4488df4bdd17564375ca62998aa Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 03:56:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(m192):=20=E5=8A=A8=E6=95=88=E5=B8=A7?= =?UTF-8?q?=E6=B3=B5=E2=80=94=E2=80=94design=20motion=20=E4=BB=A4=E7=89=8C?= =?UTF-8?q?=E6=8E=A5=E8=BF=9B=20iced=20=E7=9A=84=E6=9C=89=E7=95=8C?= =?UTF-8?q?=E5=B8=A7=E8=AE=A2=E9=98=85=20+=20=E9=A6=96=E6=89=B9=203=20?= =?UTF-8?q?=E6=9D=A1=E5=85=A5=E5=9C=BA=E5=8A=A8=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 令牌早就在 `crates/design/src/motion/` 里齐着(16 档时长 / 可采样缓动 / 弹簧二维表 / MotionScale 降级),但适配层从没消费过,所以一切状态变化都是硬切。本次补的正是那条中间层: 新增 `components/motion/`(时间轴,不是控件家族) - `pump.rs`:`Slot`(boot/screen/detail 三个互斥触发点)× `Spec`(时长档 × 缓动档, **唯一一张表**,页面拿不到「给我 250ms」这种口子,D31)→ 进度采样;时间轴按 16ms 结算 (≈60 次/秒),到期轨道**当场删除**。 - `mod.rs`:进程内单时间轴(thread-local;iced 在事件循环线程上串行调 update/view, 与 `App::update(&mut self)` 同一条纪律,测试也因此天然隔离)+ `enter`/`shifted` 两个页面入口。 - **默认 NONE**:只有产品 `on_boot` 按档位打开;`pages_demo` 这类截图 harness 从不打开。 接线(都接在既有那条链上,不另开循环) - `App::needs_frames` 多一条「有轨道」分支 → 与截图 / Busy / toast / watch_until 共享**同一个布尔**, `window::frames()` 在源码里仍只有一处(守卫:双订阅就红)。 - `on_frame` 每帧推进时间轴;`on_heartbeat` 不变(有帧时不推进)。 - 定档策略集中在 `update/boot_env.rs::motion_scale`:截图档 NONE(D30 出图可复现)、 CPU 渲染档 HALF、GPU 档 STANDARD,`ULL_MOTION` 可强制。 首批 3 条动效(`pages::render` 一个漏斗,静息恒为 (0,0) ⇒ 既有截图基线一张都不用重出) - 首屏进入 16px / 换页 12px(纵向) / 列表↔详情 8px(横向); 时长 400/300/350ms 全部取自 `DurationToken`,曲线 `Easing::Standard` (`EmphasizedDecelerate` 把 75% 位移压在 51ms 内走完,12px 的整页平移会像跳一格)。 守卫与取证 - 11 条帧泵算术守卫 + 4 条接线守卫(含「NONE 档帧采样次数必须为 0」与「帧订阅源码只出现一处」); - 5 条变异证明全部复现(删退订 / 删 NONE 短路 / 删 needs_frames 分支 / 删 16ms 闸门 / 加第二条帧订阅 ⇒ 对应用例红); - 多帧序列图 12 张在 `DevDocs/artifacts/m192/`(t=0 / 半程 / 末 + 静息基线, `--diff --diff-scan` 实测换页 12px/6px、详情 8px); - 空闲实证:`ULL_TICK_TRACE=1` 跑真窗口,帧订阅窗口一过 `clock=idle-1hz` 且 `frames=` 冻结。 --- crates/app-iced/src/app/state.rs | 3 + crates/app-iced/src/app/subscription.rs | 10 + crates/app-iced/src/app/subscription/tests.rs | 118 +++++++ crates/app-iced/src/app/update.rs | 69 +++- crates/app-iced/src/app/update/boot_env.rs | 85 ++++- crates/app-iced/src/app/update/clock.rs | 7 +- crates/app-iced/src/components/mod.rs | 3 + crates/app-iced/src/components/motion/mod.rs | 152 +++++++++ crates/app-iced/src/components/motion/pump.rs | 307 ++++++++++++++++++ .../app-iced/src/components/motion/tests.rs | 279 ++++++++++++++++ crates/app-iced/src/pages/mod.rs | 27 +- 11 files changed, 1052 insertions(+), 8 deletions(-) create mode 100644 crates/app-iced/src/app/subscription/tests.rs create mode 100644 crates/app-iced/src/components/motion/mod.rs create mode 100644 crates/app-iced/src/components/motion/pump.rs create mode 100644 crates/app-iced/src/components/motion/tests.rs diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index acff1c8..7128c53 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -56,6 +56,9 @@ pub(super) const TICK: std::time::Duration = std::time::Duration::from_millis(TI /// 实现用的是 `Task::perform(async { sleep(1s) })` 的自续链:[`App::heartbeat`]。它每秒只唤醒 /// 一次、只触发**一帧**重绘,而 `frames()` 是每秒几百帧。为什么不用 `iced::time::every`: /// 它挂在 tokio/smol 后端下,本 crate 的执行器是 thread-pool(Cargo.toml 不在 scope)。 +/// +/// **M192 的第三条时钟不在这里**:动效(`components::motion`)也只在「有轨道」时借用帧订阅, +/// 最后一个轨道到期就清空 ⇒ 下一拍 [`App::needs_frames`] 自动退回这条 1 Hz 心跳。 pub(super) const IDLE_TICK: std::time::Duration = std::time::Duration::from_secs(1); /// 派发指令后保持帧订阅的观察窗(见 [`App::watch_until`])。 diff --git a/crates/app-iced/src/app/subscription.rs b/crates/app-iced/src/app/subscription.rs index 1511ebb..321b67a 100644 --- a/crates/app-iced/src/app/subscription.rs +++ b/crates/app-iced/src/app/subscription.rs @@ -11,10 +11,14 @@ //! | 条件 | 为什么需要 33 ms 节拍 | //! |---|---| //! | 截图通道 | 第 3 帧要落盘 | +//! | **有动画在跑**(M192) | 动效时间轴要逐帧采样(帧泵自己按 16 ms 结算,见 `components::motion`) | //! | 有 `Busy` / `Running` 实例 | 启动进度、日志尾随都靠 `backend.tick` 推进 | //! | 有未过期的 toast | toast 按毫秒倒计时 | //! | 刚派发过指令(3 s 观察窗) | 真 host 的异步事件要靠节拍抽出来 | //! +//! 上面五条**共享同一个布尔**:订阅表里 `window::frames()` 只出现一处(写在同一个 `if` 里), +//! 所以「动画」与「观察窗 / Busy」同时成立时也只有一条订阅——不双订阅靠的是结构,不是判重。 +//! //! 空闲态**一条帧消息都不来** ⇒ 窗口不重绘、`refresh_view` 不跑(配合 `view_dirty`)。 //! //! ## 空闲态的时钟从哪来(M90 r3 / P2-2) @@ -43,6 +47,7 @@ use std::time::Instant; use iced::{window, Subscription}; +use super::components::motion; use super::message::Message; use super::state::App; @@ -85,6 +90,8 @@ impl App { /// 现在要不要**时钟**(见模块文档的表)。 pub(super) fn needs_frames(&self) -> bool { self.has_screenshot() + // M192:只有动画在跑时才多这一条(帧泵清空轨道后立刻为假 ⇒ 退回 1 Hz 心跳)。 + || motion::needs_frames() || !self.state.toasts.is_empty() || !self.state.running().is_empty() // M175:Java 扫描进行中(进度 3/7 要动起来,否则用户只看到「正在扫描」卡着)。 @@ -94,3 +101,6 @@ impl App { .is_some_and(|deadline| Instant::now() < deadline) } } + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/app/subscription/tests.rs b/crates/app-iced/src/app/subscription/tests.rs new file mode 100644 index 0000000..fb6291a --- /dev/null +++ b/crates/app-iced/src/app/subscription/tests.rs @@ -0,0 +1,118 @@ +//! 帧订阅的**有界**守卫(M192):动画期间订阅、结束即退订、关掉动效一帧都不订阅。 +//! +//! 这一组是**接线级**的(走真的 `App::needs_frames`),与 `components::motion::tests` 的算术守卫 +//! 互补:那边证明帧泵会清空轨道,这边证明清空之后订阅表真的跟着变干净。 +//! +//! 时间轴用 `motion::advance_by` 摆(不等真实时钟),每个用例在自己的线程上跑,互不干扰。 + +use super::*; +use crate::app::components::motion::{Slot, FRAME_MS}; +use crate::cli::Options; +use crate::theme::Families; +use unlucky_design::color::SchemeFlavor; +use unlucky_design::motion::MotionScale; +use unlucky_presenter::mock::MockBackend; +use unlucky_presenter::settings_bridge::SettingsBridge; + +const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", +}; + +/// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。 +fn headless() -> App { + App::with_presenter( + Options::default(), + FAMILIES, + SchemeFlavor::Light, + Box::new(MockBackend::new()), + SettingsBridge::memory_only(), + ) +} + +/// 把时间轴推到全部轨道结束(每步 16 ms,上限 20 s)。 +fn run_until_idle() { + for _ in 0..1250 { + if !motion::needs_frames() { + return; + } + motion::advance_by(FRAME_MS); + } +} + +/// **动画期间订阅帧、结束立刻退订**(空闲回到 1 Hz 心跳)。 +/// +/// 变异证明:把 `App::needs_frames` 里的 `motion::needs_frames()` 分支删掉 ⇒ 第二条断言红; +/// 把 `Pump::advance` 的「到期即删除」删掉 ⇒ 第三条断言红(跑完还在订阅)。 +#[test] +fn frames_are_subscribed_while_animating_and_dropped_afterwards() { + motion::reset(); + let mut app = headless(); + app.watch_until = None; + assert!(!app.needs_frames(), "前提:空闲交互态本来就不订阅帧"); + + motion::configure(MotionScale::STANDARD, None); + // 首帧:页面主体入场(Boot 槽) + motion::enter(1, Some("survival")); + assert!(motion::needs_frames(), "起跑后帧泵必须有轨道"); + assert!(app.needs_frames(), "有动画在跑时必须订阅帧"); + + run_until_idle(); + assert!(!motion::needs_frames(), "动画跑完帧泵必须清空"); + assert!( + !app.needs_frames(), + "动画结束后必须退回 1 Hz 心跳(占着帧订阅 = 空闲烧帧)" + ); + motion::reset(); +} + +/// **`MotionScale::NONE` 真的零订阅**:不是「跑一遍 0 时长动画」。 +/// +/// 变异证明:把 `Pump::play` 的 `is_off()` 分支删掉 ⇒ 帧订阅次数非 0,本用例红。 +#[test] +fn no_scale_requests_no_frames_at_all() { + motion::reset(); + let app = headless(); + motion::configure(MotionScale::NONE, None); + + // 三种状态变化:首屏 / 换页 / 换详情 + assert_eq!(motion::enter(1, None), (0.0, 0.0)); + assert_eq!(motion::enter(2, Some("survival")), (0.0, 0.0)); + assert_eq!(motion::enter(2, Some("creative")), (0.0, 0.0)); + run_until_idle(); + + assert!(!motion::needs_frames()); + assert!(!app.needs_frames(), "关掉动效后一帧都不该订阅"); + assert_eq!(motion::samples(), 0, "NONE 档的帧采样次数必须是 0"); + motion::reset(); +} + +/// 帧订阅在源码里**只出现一处**:五条理由共享同一个布尔,所以不存在「动画与 Busy 各订一条」。 +/// +/// 这是结构守卫(扫自己的源码):谁另起一条 `window::frames()`,本用例立刻红。 +#[test] +fn there_is_exactly_one_frame_subscription() { + let source = include_str!("../subscription.rs"); + let product = source.split("#[cfg(test)]").next().unwrap_or(source); + let code = product + .lines() + .filter(|line| !line.trim_start().starts_with("//")) + .collect::>() + .join( + " +", + ); + assert_eq!( + code.matches("window::frames()").count(), + 1, + "`window::frames()` 只许出现一处代码(五条理由共享同一个 if)" + ); +} + +/// 槽枚举的可见性检查:`Slot` 只经帧泵导出(页面拿不到「给我 250ms」这种口子)。 +#[test] +fn slots_are_the_three_documented_ones() { + assert_eq!(Slot::ALL.len(), 3); + assert_eq!(Slot::ALL.map(Slot::name), ["boot", "screen", "detail"]); +} diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index cca731e..d4297b6 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -20,11 +20,14 @@ //! //! | 场景 | 时钟 | 周期 | 谁触发 | //! |---|---|---|---| -//! | 截图 / 有 Busy-Running 实例 / 有 toast / 派发后 3 s 观察窗 | `window::frames()` + 闸门 | 33 ms | [`App::needs_frames`] | +//! | 截图 / **有动画在跑(M192)** / 有 Busy-Running 实例 / 有 toast / 派发后 3 s 观察窗 | `window::frames()` + 闸门 | 33 ms | [`App::needs_frames`] | //! | 其余(空闲) | `Heartbeat` 自续链 | 1 s | [`App::heartbeat`] | //! //! 两条时钟不会同时干活:心跳在 `needs_frames()` 为真时**只续排、不推进**,忙碌结束自动接回。 +//! 动效那条**不新开循环**:它只是给同一个布尔加一个分支(有轨道才为真),轨道到期当场清空, +//! 下一拍就退回心跳(见 `components::motion`)。 +use crate::app::components::motion; use crate::app::diag::diag; use std::time::Instant; @@ -183,6 +186,10 @@ impl App { fn on_boot(&mut self) -> Task { let mut tasks = vec![window::latest().map(Message::WindowReady)]; + // M192:给动效帧泵定档(截图档关、CPU 档半速、GPU 档正常;`ULL_MOTION*` 可覆盖)。 + // 必须在第一次 `view()` 之前:页面主体的入场由 `pages::render` 采样,配置晚了首屏就没有动效。 + self.configure_motion(); + // M117:启动日志一行 —— `[auth] msa client_id=configured|missing source=env|build|none`。 // 判据(配没配 + 从哪来)在 `core::auth::microsoft::client_id_status()`,零网络; // 本 crate 不直接依赖 core,所以经 `unlucky_cli`(已是本 crate 的依赖)出面, @@ -242,6 +249,25 @@ impl App { Task::batch(tasks) } + /// M192:给帧泵定档(策略全在 [`boot_env::motion_scale`],这里只搬输入)。 + fn configure_motion(&mut self) { + let pin = boot_env::env_motion_pin(); + let gpu = matches!( + super::backend::effective_kind(), + Some(crate::renderer::Kind::Wgpu) + ); + let scale = boot_env::motion_scale(self.options.screenshot.is_some(), gpu, pin.is_some()); + motion::configure(scale, pin); + // 打**帧泵实际拿到的**档位(不是刚算出的那个):配置没生效时这一行会露馅。 + diag!( + "[motion] scale={:.2} pin={:?} gpu={} screenshot={}", + motion::scale().factor(), + pin, + gpu, + self.options.screenshot.is_some() + ); + } + /// 空闲心跳:只有「不需要帧节拍」时才推进一拍,然后**无条件续排**下一拍。 /// /// 为什么无条件续排:忙碌结束(游戏退出、toast 过期、观察窗关闭)之后没有别的东西会叫醒 @@ -256,13 +282,52 @@ impl App { heartbeat() } - /// 一帧画完:①够 33 ms 就推进一拍 presenter ②截图模式在第 [`CAPTURE_AT_FRAME`] 帧出图。 + /// 出图取证:让这一次进程真的发生一次「换页」或「列表 → 详情」(见 [`boot_env::MotionFixture`])。 + fn motion_fixture(&mut self) { + use boot_env::MotionFixture; + let Some(fixture) = boot_env::env_motion_fixture() else { + return; + }; + let route = match (fixture, self.frames) { + // 列表 → 详情:第 1 帧先回列表(详情收起来),第 2 帧再点进第一条实例。 + (MotionFixture::Detail, 1) => Route::Instances, + (MotionFixture::Detail, 2) => { + let Some(first) = self + .state + .tiles() + .first() + .map(|cell| cell.instance.id.clone()) + else { + return; + }; + Route::Detail(first) + } + (MotionFixture::Route(route), 1) => route, + _ => return, + }; + diag!("[motion] ULL_MOTION_FIXTURE → {route:?}"); + self.state.navigate(route); + self.view_dirty = true; + self.refresh_view(); + } + + /// 一帧画完:①动效时间轴推进 ②够 33 ms 就推进一拍 presenter ③截图模式在第 + /// [`CAPTURE_AT_FRAME`] 帧出图。 /// /// 时间闸门按**真实流逝时间**判(不是数帧):掉帧不会把游戏启动进度拖慢, /// 也不会在慢机器上把 1.5 s 的下载阶段拉成 4 s。 fn on_frame(&mut self) -> Task { self.frames += 1; + // M192:动效时间轴。**每帧都调**,帧率上限在帧泵内部(16 ms 一次结算),所以忙碌态的 + // 近千 fps 不会变成近千次重排;动画结束后帧泵自动清空,下一拍订阅表就不再有 `frames()`。 + motion::advance(); + // 出图取证(`ULL_MOTION_FIXTURE`):前两帧各做一次**真的**状态变化。 + // 没有这步,截图通道每一帧画的都是同一屏,页面切换 / 列表↔详情的入场就没有可拍的相位。 + if self.frames <= 2 { + self.motion_fixture(); + } + let now = Instant::now(); let elapsed = now.saturating_duration_since(self.last_tick); if elapsed >= TICK { diff --git a/crates/app-iced/src/app/update/boot_env.rs b/crates/app-iced/src/app/update/boot_env.rs index 2e77b4e..a4b1789 100644 --- a/crates/app-iced/src/app/update/boot_env.rs +++ b/crates/app-iced/src/app/update/boot_env.rs @@ -1,8 +1,11 @@ -//! 启动期的取证环境变量解析(`ULL_START_ROUTE`)。 +//! 启动期的取证环境变量解析(`ULL_START_ROUTE` / 动效三个开关)。 //! //! 从 `app/update.rs` 原样搬出(那个文件在 M105/M106/M109/M113 之后贴着 400 行硬线): -//! **纯函数、无状态**,只是取值表长到了七条。 +//! **纯函数、无状态**,只是取值表长到了十条。M192 在这里加的是动效那一组 +//! ([`motion_scale`] / [`env_motion_pin`] / [`env_motion_fixture`]):档位策略只有这一处, +//! 帧泵自己不读环境变量。 +use unlucky_design::motion::MotionScale; use unlucky_presenter::router::Route; /// 取证开关 `ULL_START_ROUTE` 的取值解析(`settings` / `instances` / `download` / `content` / `logs`)。 @@ -25,6 +28,84 @@ pub(super) fn env_start_route() -> Option { } } +/// 动效档位与相位钉住(M192):帧泵**只在产品的 `on_boot` 里定档**。 +/// +/// ## 为什么档位在这里算 +/// +/// 三个输入只有启动这一拍齐全:①是不是截图通道(`--screenshot`)②实际渲染后端 +/// (`backend::effective_kind()`,不是 `--renderer` 的请求值)③`ULL_MOTION` 的强制值。 +/// 帧泵自己(`components::motion`)不读环境变量,它只接受一个 [`MotionScale`]——这样动效策略 +/// 只有一处,测试也不必摆环境变量。 +/// +/// | 条件 | 档位 | 为什么 | +/// |---|---|---| +/// | `ULL_MOTION=off` | `NONE` | 一键关(排障 / 想量基线时用) | +/// | 截图通道,且没给 `ULL_MOTION_AT` | `NONE` | D30:出图不许有动画(否则第 3 帧抓到中间态,图就不可复现) | +/// | CPU 渲染(`tiny-skia`) | `HALF` | 时长减半 = 帧数减半。位移是**布局量**,CPU 档最贵的就是逐帧重排 | +/// | 其余(`wgpu`) | `STANDARD` | —— | +/// | `ULL_MOTION=half` / `full` | 强制 | 覆盖上表(出图矩阵要在 GPU 上量同一段动效) | +/// +/// 「减少动效」的用户开关**还不存在**(`core/src/setting` 里没有 animation key,见报告 follow-up), +/// 所以本函数只认上面这些来源。 +pub(super) fn motion_scale(screenshot: bool, gpu: bool, pinned: bool) -> MotionScale { + match std::env::var("ULL_MOTION").ok().as_deref().map(str::trim) { + Some("off") | Some("none") => return MotionScale::NONE, + Some("half") => return MotionScale::HALF, + Some("full") => return MotionScale::STANDARD, + _ => {} + } + if pinned { + // 取证相位优先于「截图必须静」,否则多帧序列图一张都出不来。 + return MotionScale::STANDARD; + } + if screenshot { + return MotionScale::NONE; + } + if gpu { + MotionScale::STANDARD + } else { + MotionScale::HALF + } +} + +/// `ULL_MOTION_AT=`:把动画时钟钉在某一毫秒(多帧序列图取证)。 +/// +/// 语义与 `ULL_APPEARANCE_*` / `ULL_DIALOG_FIXTURE` 一致:只影响这一次进程。没有它,第 3 帧落在 +/// 动画的哪个相位取决于这台机器的渲染速度 —— 那样「0% / 50% / 100% 三张图」是抓阄抓出来的。 +pub(super) fn env_motion_pin() -> Option { + std::env::var("ULL_MOTION_AT") + .ok()? + .trim() + .parse::() + .ok() + .filter(|ms| ms.is_finite() && *ms >= 0.0) +} + +/// `ULL_MOTION_FIXTURE`:第 1 帧之后**真的**做一次状态变化(多帧序列图靠它)。 +/// +/// 为什么要制造变化:页面主体的入场只在「内容标识变了」时起跑(`motion::enter`),而截图通道 +/// 从第一帧起画的就是同一页——不制造一次真实的路由变化,就没有可拍的动效。 +#[derive(Debug, Clone)] +pub(super) enum MotionFixture { + /// 跳到一个一级页(走「换页」那一档)。 + Route(Route), + /// 「列表 → 详情」:先回列表(第 1 帧),再点进第一条实例(第 2 帧)。 + Detail, +} + +/// `ULL_MOTION_FIXTURE=`。 +pub(super) fn env_motion_fixture() -> Option { + Some(match std::env::var("ULL_MOTION_FIXTURE").ok()?.trim() { + "instances" => MotionFixture::Route(Route::Instances), + "download" => MotionFixture::Route(Route::Download), + "logs" => MotionFixture::Route(Route::Logs), + "settings" => MotionFixture::Route(Route::Settings), + "content" => MotionFixture::Route(Route::Content), + "detail" => MotionFixture::Detail, + _ => return None, + }) +} + /// 把启动那一拍的**事实**写进启动器自己的日志(M187 / r1 复审 P2-4)。 /// /// ## 为什么这一拍只能「先记、后落」 diff --git a/crates/app-iced/src/app/update/clock.rs b/crates/app-iced/src/app/update/clock.rs index bf79ccd..184c82b 100644 --- a/crates/app-iced/src/app/update/clock.rs +++ b/crates/app-iced/src/app/update/clock.rs @@ -94,7 +94,7 @@ impl App { // 两种时钟都用同一把尺子,所以 trace 的密度本身就是「时钟在跑」的证据。 if bucket_after != bucket_before && std::env::var_os("ULL_TICK_TRACE").is_some() { diag!( - "[tick] n={} frames={} refreshes={} clock={}", + "[tick] n={} frames={} refreshes={} clock={} motion={}", self.ticks, self.frames, self.refreshes, @@ -102,7 +102,10 @@ impl App { "frames" } else { "idle-1hz" - } + }, + // M192:动效时间轴已结算的帧数(每秒一行 ⇒ 这一列的增长速度就是动画的实际帧率, + // 也是「动画期间重绘成本」的记账口径,见报告) + crate::app::components::motion::samples() ); } } diff --git a/crates/app-iced/src/components/mod.rs b/crates/app-iced/src/components/mod.rs index b5289b6..8c26d97 100644 --- a/crates/app-iced/src/components/mod.rs +++ b/crates/app-iced/src/components/mod.rs @@ -11,6 +11,7 @@ //! buttons/ ← COMPBASE(12b §1 按钮家族 + §2 IconButton) //! divider.rs ← COMPBASE(15 §22) //! scroll.rs ← M173:竖直滚动区的滚动条槽(轨道不压内容) +//! motion/ ← M192:动效帧泵(时间轴,不是控件家族;页面经 `pages::render` 用它) //! selection/ ← 本文件只放桩;后续 mission 在自己的目录里填(§18/§19/§10/§13) //! inputs/ ← 同上(§9/§20/§11/§8) //! feedback/ ← 同上(§12/§14/§7/§15) @@ -52,6 +53,8 @@ pub mod gallery; pub mod inputs; pub mod interaction; pub mod layout; +/// M192:动效帧泵(时间轴,不是控件家族)。页面只经 `pages::render` 用它,见模块文档。 +pub mod motion; pub mod navigation; pub mod scroll; pub mod selection; diff --git a/crates/app-iced/src/components/motion/mod.rs b/crates/app-iced/src/components/motion/mod.rs new file mode 100644 index 0000000..0df9607 --- /dev/null +++ b/crates/app-iced/src/components/motion/mod.rs @@ -0,0 +1,152 @@ +//! **动效帧泵**(M192):design 的 motion 令牌 → iced 的**有界**帧订阅。 +//! +//! ## 这个模块解决什么 +//! +//! `crates/design/src/motion/` 里令牌一直齐着(16 档时长 / 可采样缓动 / 二维弹簧表 / +//! `MotionScale::NONE` 降级),但适配层从没消费过 ⇒ 所有状态变化都是硬切。本模块是那条缺失的 +//! 中间层:**谁能动**([`Slot`])、**动多久**([`spec`] 的令牌表)、**什么时候该订阅帧** +//! ([`needs_frames`])。 +//! +//! ## 有界泵的契约(三条,都有守卫) +//! +//! | 契约 | 为什么 | 守在哪 | +//! |---|---|---| +//! | 只在**有轨道**时订阅帧 | `window::frames()` 一订阅就自持重绘循环(实测几百帧/秒) | `pump::tests::a_finished_animation_stops_asking_for_frames` | +//! | 轨道到期**当场删除**,不靠「下次状态变化」 | 删晚了就是空闲烧帧 | 同上(把 `advance` 的清理删掉就红) | +//! | 每帧推进**不密于** [`FRAME_MS`](16 ms ≈ 60/s) | 帧到得再快也只该改这么多次布局 | `pump::tests::frames_are_capped_at_sixty_per_second` | +//! +//! 接法是**加进**既有那条链,不是另起一条:`App::needs_frames()` 多一个「有轨道」的分支, +//! 订阅表仍然是**一处** `if`(`app/subscription.rs`),所以与 `watch_until` / Busy 续订/ 心跳 +//! 天然不会双订阅——它们共享同一个布尔。 +//! +//! ## `MotionScale::NONE` 是**真归零** +//! +//! 关掉动效 ≠ 跑一遍 0 时长动画:`Pump::play` 在 `is_off()` 时**不插入轨道**,`progress` 于是 +//! 直接返回 `1.0`(到位),`needs_frames()` 恒为假 ⇒ **一帧都不订阅**。守卫见 +//! `pump::tests::none_scale_never_starts_a_track`。 +//! +//! ## 为什么是 thread-local 而不是 `App` 的字段 +//! +//! 它是**时间轴**(`(槽 → 进度)` 一张表 + 一个时钟),不是应用状态:没有任何业务语义,也不需要 +//! 跨进程持久。iced 在事件循环线程上串行调 `update` / `view`(这与 `App::update(&mut self)` 能存在 +//! 是同一条纪律),所以一个线程局部就够,且**测试天然隔离**(`cargo test` 每个用例一个线程, +//! 不会出现「另一条线的动画把 `needs_frames()` 顶成真」的假红)。 +//! +//! ## 页面怎么用 +//! +//! 只有一个入口:[`enter`](`pages/mod.rs::render` 每帧调一次)+ [`shifted`](把位移套到树上)。 +//! 页面**拿不到**时长/缓动/弹簧参数——那是 [`spec`] 的事。 +//! +//! 位移走 `container` 的 `padding`:动画期间内容比容器少几像素高,末帧归零后与静息**逐像素相同**。 +//! 不做逐像素 α(D27:CPU 档零运行时半透明)也不做模糊(同上),所以两个渲染后端出图一致。 + +mod pump; + +// 本 mission 里这五个只被测试与文档引用(产品路径只经 `enter`/`shifted`,拿不到时长/缓动), +// 所以显式 `allow` 一下——与 `interaction::Tracker` 那条同一条理由:它们是对外口径的一部分 +// (`spec` 是唯一一张规格表),不该因为「暂时没人调」就被删掉。 +#[allow(unused_imports)] +pub use pump::{offset, spec, Slot, Spec, FRAME_MS}; + +use std::cell::{Cell, RefCell}; +use std::time::Instant; + +use iced::widget::container; +use iced::{Element, Length, Padding}; + +use unlucky_design::motion::MotionScale; + +// clippy 1.99 的 `missing_const_for_thread_local` 对**已经是** `const { … }` 的初始化也报(本仓 +// 之前没有 thread_local,所以这条是第一次撞上):换过三种写法都照报,属于误报。初始化确实已是 +// 常量,所以按项显式放过(上在宏外面不算,lint 是对展开后的 item 报的)。 +thread_local! { + /// 进程内唯一时间轴(见模块文档「为什么是 thread-local」)。 + #[allow(clippy::missing_const_for_thread_local)] + static PUMP: RefCell = const { RefCell::new(pump::Pump::new()) }; + /// 上次收到帧的时刻(`advance` 用它把「帧到达」换算成流逝毫秒)。 + #[allow(clippy::missing_const_for_thread_local)] + static LAST_FRAME: Cell> = const { Cell::new(None) }; +} + +/// 定档:`on_boot` 调一次(截图档给 `NONE`,CPU 档给 `HALF`,见 `app/update/boot_env.rs`)。 +pub fn configure(scale: MotionScale, pin: Option) { + PUMP.with(|pump| pump.borrow_mut().configure(scale, pin)); +} + +/// 当前档位。 +pub fn scale() -> MotionScale { + PUMP.with(|pump| pump.borrow().scale()) +} + +/// 已推进的帧数。 +pub fn samples() -> u64 { + PUMP.with(|pump| pump.borrow().samples()) +} + +/// 现在有没有动画在跑(`App::needs_frames` 的一个分支)。 +pub fn needs_frames() -> bool { + PUMP.with(|pump| pump.borrow().is_animating()) +} + +/// 页主体入场:`(screen, detail)` 任一标识变了就跑对应槽;返回**取整后的位移**。 +/// +/// 每帧调一次(`pages/mod.rs::render`)。`detail` 是详情页的实例 id(`None` = 没选中任何一行)。 +/// 没有动画在跑时它只做两次比较,不做任何布局。 +pub fn enter(screen: u64, detail: Option<&str>) -> (f32, f32) { + PUMP.with(|pump| pump.borrow_mut().enter(screen, detail)) +} + +/// 推进时间轴(`App::on_frame` 每帧调一次)。 +pub fn advance() { + let now = Instant::now(); + let elapsed_ms = LAST_FRAME.with(|last| { + let previous = last.replace(Some(now)); + previous.map_or(0.0, |previous| { + now.saturating_duration_since(previous).as_secs_f64() * 1000.0 + }) + }); + PUMP.with(|pump| { + pump.borrow_mut().advance(elapsed_ms); + }); +} + +/// 把入场位移套到树上。 +/// +/// 位移为 `(0, 0)` 时**原样返回**(不额外套一层容器):静息态与「没有动效」的代码路径逐像素相同, +/// 这也是既有截图基线一张都不用重出的原因。 +pub fn shifted<'a, M: 'a>( + element: impl Into>, + (dx, dy): (f32, f32), +) -> Element<'a, M> { + let element = element.into(); + if dx == 0.0 && dy == 0.0 { + return element; + } + container(element) + .width(Length::Fill) + .height(Length::Fill) + .padding(Padding { + top: dy, + right: 0.0, + bottom: 0.0, + left: dx, + }) + .into() +} + +#[cfg(test)] +pub fn reset() { + PUMP.with(|pump| *pump.borrow_mut() = pump::Pump::new()); + LAST_FRAME.with(|last| last.set(None)); +} + +/// 测试专用:按给定毫秒推进(`advance` 读真实时钟,单元测试不能等它)。 +#[cfg(test)] +pub fn advance_by(ms: f64) { + PUMP.with(|pump| { + pump.borrow_mut().advance(ms); + }); +} + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/components/motion/pump.rs b/crates/app-iced/src/components/motion/pump.rs new file mode 100644 index 0000000..f0bac6a --- /dev/null +++ b/crates/app-iced/src/components/motion/pump.rs @@ -0,0 +1,307 @@ +//! 帧泵的**纯逻辑**:槽 / 规格表 / 时间轴 / 进度采样。 +//! +//! 这一层不认识 iced、不认识线程、不读环境变量——那些都在 [`super`]。分开的理由很实际: +//! 「帧间隔上限」「结束后必须清空」这两条守卫是**算术性质**,用本地 `Pump` 直接摆时间轴就能判死, +//! 不必起窗口、不必等真实时间(那两条最容易写成「看起来在跑」的假绿)。 +//! +//! ## 三个概念 +//! +//! | 名字 | 是什么 | 谁定 | +//! |---|---|---| +//! | [`Slot`] | 一个**同屏互斥**的动画位(首屏 / 换页 / 列表↔详情) | 本文件 | +//! | [`Spec`] | 该位的时长档 + 缓动档,**只能**是 design 令牌 | [`spec`](全项目唯一一张表) | +//! | [`Pump`] | 一条时间轴 + 每槽最多一条轨道 | 本文件 | +//! +//! ## 为什么规格表在代码里而不是页面里 +//! +//! D31:弹簧/时长令牌是**表**,页面只许按表取、不许自调参数。所以页面拿不到「给我 250ms」这种 +//! 口子——它只能说「我要换页了」([`Pump::enter`]),用哪一档由 [`spec`] 决定。改观感只改这张表。 + +use std::collections::BTreeMap; + +use unlucky_design::motion::{DurationToken, Easing, MotionScale}; +use unlucky_design::spacing::Space; + +/// 逐帧推进的**最短间隔**(16 ms ≈ 60 次/秒)。 +/// +/// 为什么需要它:`window::frames()` 的到达频率由合成器/渲染器决定,软件渲染实测能到几百帧/秒。 +/// 帧数本身不贵(贵的是整窗重绘,那由「订阅只在动画期间开着」管住),但**每帧都改布局**会让 +/// 页面每毫秒 reflow 一次。所以时间轴按 16 ms 结算:到得再快也只推进这么多次。 +pub const FRAME_MS: f64 = 16.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 { + /// 起跑时刻(动画时间轴的坐标,毫秒)。 + start_ms: f64, + spec: Spec, +} + +/// 帧泵:一条时间轴 + 每槽一条轨道。 +/// +/// **默认是关的**(`MotionScale::NONE`):产品启动时由 `App::on_boot` 按档位打开,而 +/// `pages_demo` 这类截图 harness 从不打开——D30 的「同一份代码出同一张图」因此不受动效影响。 +#[derive(Debug)] +pub struct Pump { + scale: MotionScale, + /// `Some(ms)` = 钉住相位(多帧序列图取证;见 `ULL_MOTION_AT`)。 + pin: Option, + now_ms: f64, + pending_ms: f64, + tracks: BTreeMap, + screen: Option, + detail: Option, + painted: bool, + samples: u64, +} + +impl Pump { + /// 出厂态:关着、没跑过、没有轨道。 + pub const fn new() -> Self { + Self { + scale: MotionScale::NONE, + pin: None, + now_ms: 0.0, + pending_ms: 0.0, + tracks: BTreeMap::new(), + screen: None, + detail: None, + painted: false, + samples: 0, + } + } + + /// 定档(启动一拍调一次)。 + /// + /// 顺带**重置内容标识**:iced 会在处理 `Boot` 之前先问一次 `view()`(实测:那一帧早于 + /// `on_boot` 的日志),于是「第一帧」发生在档位还没定下来的时候。不重置的话首屏入场就被那次 + /// 预渲染吃掉了(`painted = true`),之后标识再没变过 ⇒ Boot 槽永远不跑。 + pub fn configure(&mut self, scale: MotionScale, pin: Option) { + self.scale = scale; + self.pin = pin; + self.painted = false; + self.screen = None; + self.detail = None; + self.pending_ms = 0.0; + if scale.is_off() { + // 关掉动效 = 直接落终态:把在跑的轨道清掉,而不是让它跑一遍 0 时长。 + self.tracks.clear(); + } + } + + /// 当前档位。 + pub fn scale(&self) -> MotionScale { + self.scale + } + + /// 已推进的帧数(守卫与 `ULL_TICK_TRACE` 用)。 + pub fn samples(&self) -> u64 { + self.samples + } + + /// 时间轴(毫秒)。 + #[cfg(test)] + pub fn now_ms(&self) -> f64 { + self.now_ms + } + + /// 现在有没有动画在跑(= 要不要订阅帧)。 + pub fn is_animating(&self) -> bool { + !self.tracks.is_empty() + } + + /// 起跑一个槽(关着动效时什么都不做——**不是**跑一遍 0 时长)。 + /// + /// 三个槽**互斥**:它们都是「页主体入场」的不同触发点,同时跑会让位移叠加(首屏 + 换页 = + /// 28px)。所以起跑新槽就丢掉旧的——语义上也对:新的一次内容变化取代上一次。 + pub fn play(&mut self, slot: Slot) { + self.tracks.clear(); + if self.scale.is_off() { + return; + } + // 钉住相位时轨道的起点固定在 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), + }, + ); + } + + /// 采样:`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) + } + + /// 当前动画时刻(钉住相位时恒为钉住值)。 + fn elapsed_ms(&self) -> f64 { + self.pin.unwrap_or(self.now_ms) + } + + /// 页主体的入场位移:**每帧调一次**,标识变了才真的起跑。 + /// + /// 三条判据(顺序即优先级): + /// 1. 第一帧 → [`Slot::Boot`](首屏也算一次「东西出现」); + /// 2. 屏变了 → [`Slot::Screen`]; + /// 3. 屏没变但详情变了 → [`Slot::Detail`]。 + /// + /// 返回值按各槽剩余量求和后**取整**:整像素位移不会让文字在动画期间发虚, + /// 末帧三个槽都是 `1.0` ⇒ 恒为 `(0, 0)` ⇒ 与静息的布局逐像素相同。 + pub fn enter(&mut self, screen: u64, detail: Option<&str>) -> (f32, f32) { + if !self.painted { + self.painted = true; + self.play(Slot::Boot); + } else if self.screen != Some(screen) { + self.play(Slot::Screen); + } else if self.detail.as_deref() != detail { + self.play(Slot::Detail); + } + self.screen = Some(screen); + if self.detail.as_deref() != detail { + self.detail = detail.map(str::to_owned); + } + + 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; + } + (dx.round(), dy.round()) + } + + /// 推进时间轴。返回 `true` = 这一帧真的推进了(进度变了 ⇒ 要重画)。 + /// + /// 三步:①不满 [`FRAME_MS`] 就不动(帧上限)②到期的轨道**当场删除**(这是「空闲不烧帧」的 + /// 唯一出口:删掉最后一个轨道之后 [`is_animating`](Self::is_animating) 立刻为假,下一拍订阅表 + /// 里就不再有 `window::frames()`)③钉住相位时时间轴不前进(取证专用)。 + pub fn advance(&mut self, dt_ms: f64) -> bool { + if self.tracks.is_empty() { + self.pending_ms = 0.0; + return false; + } + if dt_ms.is_finite() && dt_ms > 0.0 { + self.pending_ms += dt_ms; + } + if self.pending_ms < FRAME_MS { + return false; + } + let step = std::mem::take(&mut self.pending_ms); + if self.pin.is_none() { + self.now_ms += step; + } + self.samples += 1; + + let now = self.elapsed_ms(); + let finished: Vec = 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); + } + true + } +} + +impl Default for Pump { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/app-iced/src/components/motion/tests.rs b/crates/app-iced/src/components/motion/tests.rs new file mode 100644 index 0000000..647850f --- /dev/null +++ b/crates/app-iced/src/components/motion/tests.rs @@ -0,0 +1,279 @@ +//! 帧泵的守卫(M192)。全部用**本地** `Pump` 摆时间轴:不起窗口、不等真实时间,所以不会假绿。 + +use super::pump::{offset, spec, Pump, Slot, FRAME_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(); + pump.play(Slot::Screen); + assert!(pump.is_animating(), "起跑后必须有轨道(订阅帧的唯一理由)"); + + settle(&mut pump); + assert!( + !pump.is_animating(), + "动画结束必须**当场**退订(占着轨道 = 空闲烧帧)" + ); + assert_eq!(pump.progress(Slot::Screen), 1.0, "结束即到位"); + + // 再推进也不该有任何采样(订阅表里已经没有帧了) + let before = pump.samples(); + for _ in 0..60 { + assert!(!pump.advance(FRAME_MS), "没有轨道时推进必须返回 false"); + } + assert_eq!(pump.samples(), before, "空闲态一次采样都不该发生"); +} + +/// **帧间隔上限**:帧到得再快,时间轴也只按 16 ms 结算(≈60 次/秒)。 +/// +/// 抓得住:把 `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() + ); +} + +/// 采样必须单调、且末尾回到 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_eq!(pump.enter(1, None), (0.0, 0.0), "关掉动效必须直接落终态"); + assert!(!pump.is_animating()); + assert_eq!(pump.enter(2, None), (0.0, 0.0)); + assert!(!pump.is_animating()); + assert_eq!(pump.enter(2, Some("instance-7")), (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); +} + +/// 半速档(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) + let (dx, dy) = pump.enter(1, None); + assert_eq!(dx, 0.0); + assert!(dy > 0.0, "首屏应有一个向下的入场位移"); + assert!(pump.is_animating(), "首帧要跑 Boot 槽"); + assert_eq!( + pump.progress(Slot::Screen), + 1.0, + "首帧不该顺带起跑 Screen 槽" + ); + settle(&mut pump); + assert_eq!(pump.enter(1, None), (0.0, 0.0), "标识没变 ⇒ 不动"); + + // 换屏:Screen(纵向) + let (dx, dy) = pump.enter(2, None); + assert_eq!(dx, 0.0); + assert!(dy > 0.0); + assert!(pump.progress(Slot::Screen) < 1.0, "换屏要跑 Screen 槽"); + settle(&mut pump); + + // 同屏换详情:Detail(横向) + let (dx, dy) = pump.enter(2, Some("instance-7")); + assert!(dx > 0.0, "列表↔详情是横向入场"); + assert_eq!(dy, 0.0); + assert!(pump.progress(Slot::Detail) < 1.0); + settle(&mut pump); + assert_eq!(pump.enter(2, Some("instance-7")), (0.0, 0.0)); +} + +/// 换页会**取代**首屏那一档:位移不叠加(12px,不是 16+12)。 +#[test] +fn a_new_transition_supersedes_the_previous_one() { + let mut pump = standard(); + let boot_dy = offset(Slot::Boot).1; + assert_eq!(pump.enter(1, None).1, boot_dy, "首帧从 Boot 的最大位移开始"); + + // 首屏还没跑完就换页:位移必须是 Screen 那一档,而不是两档相加 + let (_, dy) = pump.enter(2, None); + assert_eq!( + dy, + offset(Slot::Screen).1, + "新的一次内容变化应取代上一次(叠加会让位移变成两档之和)" + ); + assert!(!pump.is_animating() || pump.progress(Slot::Boot) == 1.0); +} + +/// 同一屏同一详情重复绘制**永远不会**重新起跑(否则每帧都在重启动画 = 永远停不下来)。 +#[test] +fn repeated_paints_never_restart_the_animation() { + let mut pump = standard(); + pump.enter(3, Some("instance-9")); + let frames = settle(&mut pump); + assert!(frames > 0); + + for _ in 0..30 { + assert_eq!(pump.enter(3, Some("instance-9")), (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(), "钉住的相位不会自然结束(取证专用)"); +} diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index 1fb36c1..3828af7 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -143,6 +143,7 @@ use iced::{Element, Length}; use unlucky_design::elevation::MaterialLayer; use unlucky_design::spacing::Space; +use crate::app::components::motion; use crate::theme::Ui; use frame::filled; @@ -243,14 +244,36 @@ fn render<'a>( // M159:自更新提示条挂在**每一页**的页头之上(有新版 / 正在下载 / 已就位时才出现)。 // 为什么在这里而不是外壳里:外壳(`shell`)只认窗口装饰与导航,页面层才能按快照决定画什么; // 而「每一页都要能看见」这条要求又决定了它不能只挂在实例页上。 - match update::banner(ui, &model.update, model.lang.catalog()) { + let tree: Element<'a, Message> = match update::banner(ui, &model.update, model.lang.catalog()) { Some(banner) => column![banner, page] .spacing(ui.space(Space::X3)) .width(Length::Fill) .height(Length::Fill) .into(), None => page, - } + }; + + // M192:主体入场(首屏 / 换页 / 列表↔详情)。位移由帧泵按令牌表给,**静息恒为 `(0, 0)`** + // 且此时 `motion::shifted` 原样返回,所以关闭动效与「末帧」两条路径都与没有动效时逐像素相同。 + let shift = motion::enter(screen_key(model.screen), detail_key(model)); + motion::shifted(tree, shift) +} + +/// 屏的稳定标识:值变了才跑「换页」那一档([`Screen::ALL`] 的下标 + 1,`0` 留给「还没见过」)。 +fn screen_key(screen: Screen) -> u64 { + Screen::ALL + .iter() + .position(|candidate| *candidate == screen) + .map_or(0, |index| index as u64 + 1) +} + +/// 详情页的稳定标识:实例 id(`None` = 没选中任何一行)。同一个 id 重复绘制不会重启动画。 +fn detail_key(model: &Model) -> Option<&str> { + model + .instances + .detail + .as_ref() + .map(|detail| detail.id.as_str()) } /// 整窗(**harness 专用**):标题栏留白带 + 导航轨 + 当前路由的页面。