From a1876a08c082f4488df4bdd17564375ca62998aa Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 03:56:19 +0800 Subject: [PATCH 1/2] =?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 专用**):标题栏留白带 + 导航轨 + 当前路由的页面。 From 93bd7047861146bf04655586050140308b195500 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 04:52:40 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(m192):=20r1=20=E5=A4=8D=E5=AE=A1=20P1-1?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=8A=A8=E6=95=88=E8=B5=B7=E8=B7=91=E6=90=AC?= =?UTF-8?q?=E8=BF=9B=20update=20=E4=BE=A7=20+=20=E5=B8=A7=E5=9F=BA?= =?UTF-8?q?=E5=87=86=E5=85=9C=E5=BA=95=20+=20=E6=8C=89=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=E9=A1=BA=E5=BA=8F=E7=9A=84=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审的机制三步全部复核成立(iced_winit-0.14.1 `src/lib.rs:1222` 调 update、`1338` 在 `update()` 内部求值订阅表、`1233` 之后才 `build_user_interfaces` 调 view)。第一版把 「标识变了 → play」写在 `pages::render`(= view)里,于是:同一拍的订阅表看不见轨道 ⇒ `frames()` 要等下一条消息才打开;而那一帧的 dt 是整段空闲时长 ⇒ 轨道被当场判到期删掉。 用户看到的是「一帧 12/8px 偏移 + 闪跳」,比硬切更差;截图通道因为 `has_screenshot()` 恒真, 结构上不可能发现它。 修法(三件都做) - **起跑搬进 update**:新增 `app/update/motion_sync.rs::App::sync_motion`,在 `App::update` 收尾(`route_message` 之后、`subscription()` 之前)按**路由**算内容标识并 `motion::observe`。 `pages::render` 只留 `motion::sample()`(纯采样)。 顺带:`screen == None`(账户页 / AI 独立页)只记标识不起跑,于是「从它们回到某一屏」也跑换页。 - **帧基准兜底**:`observe` 起跑成功时把 `LAST_FRAME` 重置到「现在」;`Pump::advance` 再加一道 `MAX_STEP_MS`(4 帧 = 64 ms)单次步进上限——掉帧/挂起时宁可把动画放慢,也不许一帧抹掉。 - **没有窗口就不起跑**:`window.is_none()` 且不是截图通道时 `sync_motion` 直接返回(也**不记** 标识)⇒「首屏入场」等窗口到了才算第一眼,启动期那棵空树吃不掉它(原先被 1 Hz 心跳 / 既有 `an_idle_app_...` 用例照出来的就是这个边界)。 - `on_heartbeat` 也推进动效时间轴:窗口被遮挡/最小化/合成器不送绘制时,帧一个都不来,而 「有轨道」会让 1 Hz 推进整段停摆 ⇒ 轨道靠心跳按单次上限收尾(最坏 5 拍内自然退订)。 守卫(新增 5 条,全部按运行时顺序写) - `a_state_change_opens_the_frame_subscription_in_the_same_tick`:**先** `assert!(!needs_frames)` →**一条真消息**(`Message::Page(Navigate(..))`)→ `assert!(needs_frames)`,并断言 `watch_until` 仍是 `None`(排除「观察窗顺手开帧」这条假绿)。r1 那条顺序是反的,已删。 - `opening_a_detail_also_opens_the_frames_in_the_same_tick`、`pages_that_render_elsewhere_do_not_start_a_track`、 `without_a_window_nothing_animates`、`a_stale_baseline_cannot_kill_the_track_in_one_sample`。 r1 两条 P2 - P2-1:`pump::FRAME_MS` 与 `mod.rs` 契约表补写「16 ms 只卡采样/布局推进,**整窗重绘跟随渲染器** (软件渲染 ≈970 帧/秒、一次 300 ms 换页 ≈290 次整窗重绘 ≈0.30 核·秒;GPU 档 AutoVsync 40~60)」。 - P2-2:删掉 `app/subscription/tests.rs`,测试并回 `app/subscription.rs` 的 `mod tests` ⇒ 本轮 动过的文件全部落在声明 scope 内(新文件只有 `app/update/motion_sync.rs`,在 `app/update/**` 下)。 真机证据(GPU 档,`ULL_TICK_TRACE=1`,空闲 14 s 后由心跳触发一次**真实**导航, `DevDocs/artifacts/m192/idle-click-evidence-gpu-final.log`): idle: frames=96 clock=idle-1hz motion=14(连续 7 拍不动) fire: clock=frames motion=16 → 17(同一拍订阅表就开了) end : frames=102 clock=idle-1hz motion=20(自己退订) 说明:本机会话没有前台窗口(`GetForegroundWindow()==0`),合成点击(`mouse_event` 与 `PostMessage(WM_LBUTTONDOWN)`)递不进 iced 的输入层,故用「心跳第 N 秒真导航」这条同构路径; 样本次数由**绘制送达**决定(该次动效只收到 ~5 次绘制 ⇒ +6 次采样),正常可见窗口下 60 Hz 呈现 对应 ~19 次。 变异证明 7/7 红(`DevDocs/artifacts/m192/mutate.sh`,含新增 M6「删掉 sync_motion」与 M7「去掉单次步进上限」),还原后自检全绿。 编译:cargo test -p unlucky-app-iced → 0(797 passed / 2 ignored,集成 6 passed); clippy --all-targets -D warnings → 0;fmt --check → 0;build → 0。 出图基线复验:三张 rest 基线与九张序列帧的 sha256 与 r1 **逐字节相同**(常驻的那层 container 零内距)。 --- crates/app-iced/src/app/subscription.rs | 248 +++++++++++++++++- crates/app-iced/src/app/subscription/tests.rs | 118 --------- crates/app-iced/src/app/update.rs | 53 ++-- crates/app-iced/src/app/update/boot_env.rs | 22 +- crates/app-iced/src/app/update/motion_sync.rs | 166 ++++++++++++ crates/app-iced/src/components/motion/mod.rs | 70 +++-- crates/app-iced/src/components/motion/pump.rs | 85 ++++-- .../app-iced/src/components/motion/tests.rs | 118 +++++++-- crates/app-iced/src/pages/mod.rs | 25 +- 9 files changed, 665 insertions(+), 240 deletions(-) delete mode 100644 crates/app-iced/src/app/subscription/tests.rs create mode 100644 crates/app-iced/src/app/update/motion_sync.rs diff --git a/crates/app-iced/src/app/subscription.rs b/crates/app-iced/src/app/subscription.rs index 321b67a..d49ce2a 100644 --- a/crates/app-iced/src/app/subscription.rs +++ b/crates/app-iced/src/app/subscription.rs @@ -101,6 +101,250 @@ impl App { .is_some_and(|deadline| Instant::now() < deadline) } } - #[cfg(test)] -mod tests; +mod tests { + //! 帧订阅的**有界**守卫(M192):动画期间订阅、结束即退订、关掉动效一帧都不订阅。 + //! + //! ## 这一组按**运行时顺序**写(M192 r1 复审 P1-1 的教训) + //! + //! 第一版这里是「先 `motion::enter(...)` 再问 `needs_frames()`」——而 iced 的真实顺序是 + //! 「`update()`(订阅表在这里求值)→ `view()`」,于是这条守卫全绿、生产路径上却根本播不出来。 + //! 现在全部改走**真消息**:`app.update(Message::Page(Navigate(..)))` 之后再问订阅表, + //! 而且断言 `watch_until` 仍是 `None`(排除「观察窗顺手开了帧」这条假绿)。 + //! + //! 时间轴用 `motion::advance_by` 摆(不等真实时钟),每个用例在自己的线程上跑,互不干扰。 + + use super::*; + use crate::app::components::motion::{Slot, FRAME_MS}; + use crate::cli::Options; + use crate::pages; + use crate::theme::Families; + use unlucky_design::color::SchemeFlavor; + use unlucky_design::motion::MotionScale; + use unlucky_presenter::mock::MockBackend; + use unlucky_presenter::router::Route; + 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(), + ) + } + + /// 走到「真·空闲」:没有观察窗、没有忙碌实例、没有动画(首屏那一条 Boot 已跑完)。 + /// + /// `Boot` 自己就会起跑一次首屏动效(`on_boot` → `sync_motion`),所以必须先把它推完, + /// 否则后面的 `assert!(!needs_frames())` 测的是「动画还在跑」而不是「空闲」。 + fn idle_app() -> App { + motion::reset(); + motion::configure(MotionScale::STANDARD, None); + let mut app = headless(); + // 有窗口才谈得上动效(`sync_motion` 在没有窗口时直接返回,见它的文档) + app.window = Some(iced::window::Id::unique()); + let _ = app.update(Message::Boot); + app.watch_until = None; + run_until_idle(); + assert!(!motion::needs_frames(), "前提:首屏动效已经跑完"); + app + } + + /// 把时间轴推到全部轨道结束(每步 16 ms,上限 20 s)。 + fn run_until_idle() { + for _ in 0..1250 { + if !motion::needs_frames() { + return; + } + motion::advance_by(FRAME_MS); + } + } + + /// **按运行时顺序**:空闲态一条消息进来(换页)⇒ **同一拍**订阅表就要有帧;跑完立刻退订。 + /// + /// 变异证明:把起跑搬回 `view`(删掉 `App::update` 里的 `sync_motion`)⇒ 第一步的 + /// `assert!(app.needs_frames())` 红;把 `Pump::advance` 的「到期即删除」删掉 ⇒ 最后一步红。 + #[test] + fn a_state_change_opens_the_frame_subscription_in_the_same_tick() { + let mut app = idle_app(); + assert!(!app.needs_frames(), "前提:空闲交互态本来就不订阅帧"); + + // 用户点了一次导航轨:**一条**消息,之后不再补任何消息 + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download))); + + assert!( + app.watch_until.is_none(), + "换页不该打开观察窗(这里要证明帧是**动效**要来的,不是派发顺手开的)" + ); + assert!(motion::needs_frames(), "换页必须在同一拍起跑"); + assert!( + app.needs_frames(), + "同一拍的订阅表必须能看见轨道(iced 先算订阅表、后建视图 —— 复审 P1-1)" + ); + + run_until_idle(); + assert!(!motion::needs_frames(), "动画跑完帧泵必须清空"); + assert!( + !app.needs_frames(), + "动画结束后必须退回 1 Hz 心跳(占着帧订阅 = 空闲烧帧)" + ); + motion::reset(); + } + + /// 列表 ↔ 详情同样走「一条消息 ⇒ 当拍有帧」,且 `Detail` 槽真的是那条。 + #[test] + fn opening_a_detail_also_opens_the_frames_in_the_same_tick() { + let mut app = idle_app(); + // 出厂快照会自动选中第一条 ⇒ 先回列表,让「点进详情」真的是一次详情变化 + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances))); + run_until_idle(); + assert!(!app.needs_frames()); + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail( + "survival".to_string(), + )))); + assert!(app.needs_frames(), "点进详情必须当拍订阅帧"); + run_until_idle(); + assert!(!app.needs_frames()); + motion::reset(); + } + + /// 账户页 / AI 独立页**不归 pages 画**:切过去不起跑(否则白跑 300 ms 帧);切回来照旧跑。 + #[test] + fn pages_that_render_elsewhere_do_not_start_a_track() { + let mut app = idle_app(); + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Accounts))); + assert!( + !motion::needs_frames(), + "账户页没有 pages 树可动,不许白开帧" + ); + + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances))); + assert!(motion::needs_frames(), "从账户页回到某一屏仍然是换页,要跑"); + run_until_idle(); + motion::reset(); + } + + /// **没有窗口就不起跑**:`window::frames()` 在没窗口时一条消息都不会有,起跑只是白占一档; + /// 首屏那一条也因此要等窗口到了才算「第一眼」。 + #[test] + fn without_a_window_nothing_animates() { + motion::reset(); + motion::configure(MotionScale::STANDARD, None); + let mut app = headless(); + assert!(app.window.is_none(), "前提:还没拿到窗口句柄"); + let _ = app.update(Message::Boot); + app.watch_until = None; + assert!(!motion::needs_frames(), "没有窗口 ⇒ 不许起跑"); + + // 窗口来了:首屏入场这时才开始(不是被启动期那棵空树吃掉) + let _ = app.update(Message::WindowReady(Some(iced::window::Id::unique()))); + assert!(app.needs_frames(), "窗口到了之后「首屏」才起跑"); + run_until_idle(); + motion::reset(); + } + + /// **`MotionScale::NONE` 真的零订阅**:不是「跑一遍 0 时长动画」。 + /// + /// 变异证明:把 `Pump::play` 的 `is_off()` 分支删掉 ⇒ 帧采样次数非 0,本用例红。 + #[test] + fn no_scale_requests_no_frames_at_all() { + motion::reset(); + let mut app = headless(); + app.window = Some(iced::window::Id::unique()); + let _ = app.update(Message::Boot); + app.watch_until = None; + motion::configure(MotionScale::NONE, None); + + // 三种状态变化:换页 / 换详情 / 再换回来 + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download))); + assert!(!app.needs_frames(), "关掉动效后换页一帧都不该订阅"); + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail( + "survival".to_string(), + )))); + assert!(!app.needs_frames()); + run_until_idle(); + + assert!(!motion::needs_frames()); + assert_eq!(motion::samples(), 0, "NONE 档的帧采样次数必须是 0"); + motion::reset(); + } + + /// 帧订阅在**产品接线**里只出现一处:五条理由共享同一个布尔,所以不会出现「动画与 Busy 各订一条」。 + /// + /// 口径是**全仓 `src/**`**(复审 r1 备注:只扫 `subscription.rs` 抓不到别人另开订阅),只看 + /// 代码行(注释/文档里提到这个符号不算)。唯一允许的第二个文件是 `pages/harness.rs`—— + /// 那是 `pages_demo` 这条**独立 example** 自己的 iced app(不接 `App`、不进产品路径)。 + #[test] + fn there_is_exactly_one_frame_subscription() { + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = Vec::new(); + collect_rust_files(&src, &mut files); + assert!( + files.len() >= 40, + "只扫到 {} 个文件:扫描逻辑坏了", + files.len() + ); + + let mut hits = Vec::new(); + for path in files { + let text = std::fs::read_to_string(&path).expect("读源文件"); + let product = text.split("#[cfg(test)]").next().unwrap_or(&text); + let code = product + .lines() + .filter(|line| !line.trim_start().starts_with("//")) + .collect::>() + .join( + " +", + ); + let count = code.matches("window::frames()").count(); + if count > 0 { + let name = path + .strip_prefix(&src) + .unwrap_or(&path) + .to_string_lossy() + .replace(std::path::MAIN_SEPARATOR, "/"); + hits.push((name, count)); + } + } + + hits.sort(); + let expected = [ + ("app/subscription.rs".to_string(), 1_usize), + ("pages/harness.rs".to_string(), 1_usize), + ]; + assert_eq!( + hits, expected, + "`window::frames()` 只许出现在 `app/subscription.rs`(产品接线)\ + 与 `pages/harness.rs`(独立 example 的自有 app),且各一处" + ); + } + + /// 递归收集 `dir/**/*.rs`。 + fn collect_rust_files(dir: &std::path::Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).expect("读目录") { + let path = entry.expect("目录项").path(); + if path.is_dir() { + collect_rust_files(&path, out); + } else if path.extension().is_some_and(|ext| ext == "rs") { + out.push(path); + } + } + } + + /// 槽枚举的可见性检查:`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/subscription/tests.rs b/crates/app-iced/src/app/subscription/tests.rs deleted file mode 100644 index fb6291a..0000000 --- a/crates/app-iced/src/app/subscription/tests.rs +++ /dev/null @@ -1,118 +0,0 @@ -//! 帧订阅的**有界**守卫(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 d4297b6..80cdd9f 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -56,6 +56,7 @@ mod dialog; mod dialog_fixture; mod download; mod instances; +mod motion_sync; mod page_messages; mod settings; mod update_action; @@ -73,7 +74,18 @@ fn is_escape(event: &iced::keyboard::Event) -> bool { impl App { /// 一条消息进来,改状态并返回后续任务。 + /// + /// **分两段**:先派发([`App::route_message`]),再同步一次动效的内容标识。第二段必须在 + /// `update` 里而不是 `view` 里:iced 求值订阅表**早于**建视图(`app/update/motion.rs` 的模块 + /// 文档有源码行号),在 `view` 里起跑的轨道当拍订阅不上,动画会退化成「一帧偏移 + 闪跳」。 pub(super) fn update(&mut self, message: Message) -> Task { + let task = self.route_message(message); + self.sync_motion(); + task + } + + /// 把一条消息派发到它的落点(`update` 的正文;拆出来只为让 [`App::sync_motion`] 收尾)。 + fn route_message(&mut self, message: Message) -> Task { match message { Message::Boot => self.on_boot(), Message::WindowReady(id) => { @@ -273,44 +285,23 @@ impl App { /// 为什么无条件续排:忙碌结束(游戏退出、toast 过期、观察窗关闭)之后没有别的东西会叫醒 /// app,心跳链必须一直在,才能把低频时钟接回去。忙碌期间它只是每秒醒一次、什么都不做。 fn on_heartbeat(&mut self) -> Task { + // 动效时间轴也吃这一拍:窗口被遮挡 / 最小化 / 合成器不送绘制时,帧一个都不来,而 + // 「有轨道」又让下面的 1 Hz 推进整段停摆——那会让 `needs_frames()` 永远为真。心跳把 + // 轨道按单次上限([`components::motion::pump::MAX_STEP_MS`])推几步收尾,最坏的「没有帧」 + // 情况下 5 拍内自然退订;有帧时它只多推 64 ms/秒,看不出来。 + motion::advance(); if !self.needs_frames() { let now = Instant::now(); let elapsed = now.saturating_duration_since(self.last_tick).max(IDLE_TICK); self.last_tick = now; self.advance(elapsed); } + // M192 真机取证(`ULL_MOTION_FIXTURE_AT`):到点就**真的**导航一次。放在 advance 之后 ⇒ + // 这一拍是货真价实的空闲态;跳转后由 `update` 收尾的 `sync_motion` 起跑(与点击同一条路)。 + self.motion_fixture_after_idle(); heartbeat() } - /// 出图取证:让这一次进程真的发生一次「换页」或「列表 → 详情」(见 [`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`] 帧出图。 /// @@ -324,7 +315,9 @@ impl App { motion::advance(); // 出图取证(`ULL_MOTION_FIXTURE`):前两帧各做一次**真的**状态变化。 // 没有这步,截图通道每一帧画的都是同一屏,页面切换 / 列表↔详情的入场就没有可拍的相位。 - if self.frames <= 2 { + // `ULL_MOTION_FIXTURE_AT`(真机空闲取证)在的时候**不跑**这一支:两条取证路互斥, + // 否则第 1 帧就把页面换成目标页,等 `_AT` 的心跳再跳同一个目标 = 标识没变 = 没有动效。 + if self.frames <= 2 && boot_env::env_motion_fixture_at().is_none() { self.motion_fixture(); } diff --git a/crates/app-iced/src/app/update/boot_env.rs b/crates/app-iced/src/app/update/boot_env.rs index a4b1789..a075c27 100644 --- a/crates/app-iced/src/app/update/boot_env.rs +++ b/crates/app-iced/src/app/update/boot_env.rs @@ -83,7 +83,7 @@ pub(super) fn env_motion_pin() -> Option { /// `ULL_MOTION_FIXTURE`:第 1 帧之后**真的**做一次状态变化(多帧序列图靠它)。 /// -/// 为什么要制造变化:页面主体的入场只在「内容标识变了」时起跑(`motion::enter`),而截图通道 +/// 为什么要制造变化:页面主体的入场只在「内容标识变了」时起跑(`motion::observe`),而截图通道 /// 从第一帧起画的就是同一页——不制造一次真实的路由变化,就没有可拍的动效。 #[derive(Debug, Clone)] pub(super) enum MotionFixture { @@ -106,6 +106,26 @@ pub(super) fn env_motion_fixture() -> Option { }) } +/// `ULL_MOTION_FIXTURE_AT=<秒>`:启动后第 N 次心跳(≈第 N 秒)**真的**导航一次。 +/// +/// ## 为什么需要它(真机空闲取证) +/// +/// 复审 r1 要求「空闲 ≥10 s 后点一次导航轨,看 `[tick] motion=` 涨多少」。本机工作区所在会话 +/// 没有前台窗口(`GetForegroundWindow()==0`),合成鼠标事件(`mouse_event` 与 +/// `PostMessage(WM_LBUTTONDOWN)` 都试过)递不进 iced 的输入层,所以这里换一条**同构**的路: +/// 由 1 Hz 心跳(空闲态本来就有的消息)在第 N 秒做一次真实的路由跳转。 +/// +/// 两条路在 `update` 之后**完全同一段代码**:消息 → `sync_motion`(起跑)→ `subscription()` +/// (同一拍看见轨道)→ `frames()`。差别只在「消息是谁产生的」,而这正是 P1 要验的那一段。 +pub(super) fn env_motion_fixture_at() -> Option { + std::env::var("ULL_MOTION_FIXTURE_AT") + .ok()? + .trim() + .parse::() + .ok() + .filter(|beats| *beats > 0) +} + /// 把启动那一拍的**事实**写进启动器自己的日志(M187 / r1 复审 P2-4)。 /// /// ## 为什么这一拍只能「先记、后落」 diff --git a/crates/app-iced/src/app/update/motion_sync.rs b/crates/app-iced/src/app/update/motion_sync.rs new file mode 100644 index 0000000..3996dac --- /dev/null +++ b/crates/app-iced/src/app/update/motion_sync.rs @@ -0,0 +1,166 @@ +//! 动效的**起跑点**(M192 r1 复审 P1-1 修的正是这里)。 +//! +//! ## 为什么这条不能在 `view` 里 +//! +//! iced 0.14 的处理顺序是: +//! +//! ```text +//! 消息 ──► update() ← program.subscription() 在这里求值(iced_winit src/lib.rs:1338) +//! ──► build_user_interfaces() ← 这时才调 program.view(src/lib.rs:1233) +//! ``` +//! +//! 也就是说**订阅表的求值早于视图**。第一版把「内容标识变了 → 起跑」写在 `pages::render` +//! (= `view`)里,于是同一拍订阅表看不见轨道,`frames()` 要等下一条消息(最快鼠标抬起、 +//! 最慢 1 Hz 心跳)才打开;那一帧的 dt 又是整段空闲时长 ⇒ 轨道被当成「早已到期」清掉, +//! 用户看到的是一次 12/8px 的偏移闪跳,比硬切更难看。截图通道因为 `has_screenshot()` 恒真, +//! **结构上**不可能暴露这条。 +//! +//! 所以起跑搬到这里:`App::update` 处理完每一条消息后调 [`App::sync_motion`], +//! 于是「轨道存在」对**同一拍**的 `subscription()` 可见,帧订阅当拍打开。 +//! +//! ## 标识从哪来 +//! +//! 从**路由**取(`AppState::route()` 是唯一真源),不从视图模型取:账户页 / AI 独立页不走 +//! `pages::render`,用路由才能表达「这一屏不归 pages 画」(`None`)——它们之间的切换不起跑, +//! 而「从它们回到某一屏」照旧跑换页那一档(复审 r1 结论 4 的硬切顺手收口)。 + +use unlucky_presenter::router::Route; + +use crate::app::components::motion; +use crate::pages; + +use super::boot_env::MotionFixture; + +use super::*; + +impl App { + /// 每条消息处理完之后同步一次内容标识(**必须在 `update` 里**,理由见模块文档)。 + pub(in crate::app) fn sync_motion(&mut self) { + // **还没有窗口就什么都不做**(也不记标识):动效是画出来的东西,没有窗口时 + // `window::frames()` 一条消息也不会有,起跑只会白占一档。更要紧的是「首屏入场」的语义—— + // 等窗口到了再算「第一眼看到的内容」,否则首屏那一下会被启动期的空树吃掉。 + // 截图通道例外:它的窗口句柄也是异步到的,而序列图必须从第 1 帧就按钉住的相位画。 + if self.window.is_none() && !self.has_screenshot() { + return; + } + let route = self.state.route(); + let screen = screen_key(route); + let detail = match route { + Route::Detail(id) => Some(id.as_str()), + _ => None, + }; + motion::observe(screen, detail); + } + + /// 出图取证:让这一次进程真的发生一次「换页」或「列表 → 详情」(见 [`super::boot_env::MotionFixture`])。 + pub(in crate::app) fn motion_fixture(&mut self) { + let Some(fixture) = super::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(); + } + + /// 真机空闲取证:`ULL_MOTION_FIXTURE_AT=<秒>` 到点就**真的**导航一次(`on_heartbeat` 调)。 + /// + /// 为什么不用点击:[`super::boot_env::env_motion_fixture_at`] 的文档写了原因——本机会话没有 + /// 前台窗口,合成鼠标事件递不进 iced 的输入层。心跳这条路与点击在 `update` 之后是同一段代码。 + /// 返回 `true` = 这一拍真的跳了页。 + pub(in crate::app) fn motion_fixture_after_idle(&mut self) -> bool { + use std::sync::atomic::{AtomicU32, Ordering}; + /// 已经过去的心跳数(进程内一次;只有取证开关打开时才会用到)。 + static FIXTURE_BEATS: AtomicU32 = AtomicU32::new(0); + + let Some(target) = boot_env::env_motion_fixture_at() else { + return false; + }; + let beat = FIXTURE_BEATS.fetch_add(1, Ordering::Relaxed) + 1; + if beat != target { + return false; + } + let route = match boot_env::env_motion_fixture() { + Some(MotionFixture::Route(route)) => route, + Some(MotionFixture::Detail) => { + let Some(id) = self + .state + .tiles() + .first() + .map(|cell| cell.instance.id.clone()) + else { + return false; + }; + Route::Detail(id) + } + None => Route::Download, + }; + diag!("[motion] ULL_MOTION_FIXTURE_AT 到点(第 {beat} 次心跳)→ {route:?}"); + self.state.navigate(route); + self.view_dirty = true; + self.refresh_view(); + true + } +} + +/// 路由 → 屏的稳定键(`pages::Screen::ALL` 的下标 + 1;`None` = 不归 pages 画)。 +/// +/// 与 `pages::Screen::of` 同一判据(`Screen::Instances` ↔ `Route::Instances | Route::Detail(_)`), +/// 只是把「有没有这一屏」也表达出来——`pages::Screen::of` 给的是 `Option`,这里 +/// 再折成 `Option` 给帧泵用。 +fn screen_key(route: &Route) -> Option { + let screen = pages::Screen::of(route)?; + pages::Screen::ALL + .iter() + .position(|candidate| *candidate == screen) + .map(|index| index as u64 + 1) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 路由 → 屏键:五个一级页各自唯一,`Detail` 归「实例」,账户/AI 不归 pages。 + #[test] + fn only_the_five_screens_get_a_key() { + let keys: Vec> = [ + Route::Instances, + Route::Download, + Route::Content, + Route::Settings, + Route::Logs, + Route::Detail("survival".to_string()), + Route::Accounts, + Route::Agent, + ] + .iter() + .map(screen_key) + .collect(); + + assert_eq!(&keys[..5], &[Some(1), Some(2), Some(3), Some(4), Some(5)]); + assert_eq!( + keys[5], + Some(1), + "详情页归「实例」那一屏(与 Screen::of 同源)" + ); + assert_eq!(keys[6], None, "账户页不归 pages 画"); + assert_eq!(keys[7], None, "AI 独立页不归 pages 画"); + } +} diff --git a/crates/app-iced/src/components/motion/mod.rs b/crates/app-iced/src/components/motion/mod.rs index 0df9607..5919e1c 100644 --- a/crates/app-iced/src/components/motion/mod.rs +++ b/crates/app-iced/src/components/motion/mod.rs @@ -7,13 +7,18 @@ //! 中间层:**谁能动**([`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` | +//! | **时间轴推进**不密于 [`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 续订/ 心跳 @@ -32,9 +37,17 @@ //! 是同一条纪律),所以一个线程局部就够,且**测试天然隔离**(`cargo test` 每个用例一个线程, //! 不会出现「另一条线的动画把 `needs_frames()` 顶成真」的假红)。 //! -//! ## 页面怎么用 +//! 仓内已有同样的先例(`pages/appearance/probe.rs`、`core/src/addon/pack/format/probe.rs` 都用了 +//! `#[allow(clippy::missing_const_for_thread_local)]`),所以这不是给本 crate 开的新口子。 +//! +//! ## 谁在什么时候调 +//! +//! | 时机 | 调用 | 在哪 | +//! |---|---|---| +//! | 每条消息处理完(**订阅表求值之前**) | [`observe`]:内容标识变了就起跑 | `App::sync_motion`(`app/update/motion.rs`) | +//! | 每帧画视图 | [`sample`]:只读当前位移 | `pages::render` | +//! | 每帧 | [`advance`]:推进时间轴 | `App::on_frame` | //! -//! 只有一个入口:[`enter`](`pages/mod.rs::render` 每帧调一次)+ [`shifted`](把位移套到树上)。 //! 页面**拿不到**时长/缓动/弹簧参数——那是 [`spec`] 的事。 //! //! 位移走 `container` 的 `padding`:动画期间内容比容器少几像素高,末帧归零后与静息**逐像素相同**。 @@ -42,7 +55,7 @@ mod pump; -// 本 mission 里这五个只被测试与文档引用(产品路径只经 `enter`/`shifted`,拿不到时长/缓动), +// 本 mission 里这五个只被测试与文档引用(产品路径只经 `observe`/`sample`/`shifted`,拿不到时长/缓动), // 所以显式 `allow` 一下——与 `interaction::Tracker` 那条同一条理由:它们是对外口径的一部分 // (`spec` 是唯一一张规格表),不该因为「暂时没人调」就被删掉。 #[allow(unused_imports)] @@ -56,9 +69,10 @@ use iced::{Element, Length, Padding}; use unlucky_design::motion::MotionScale; -// clippy 1.99 的 `missing_const_for_thread_local` 对**已经是** `const { … }` 的初始化也报(本仓 -// 之前没有 thread_local,所以这条是第一次撞上):换过三种写法都照报,属于误报。初始化确实已是 -// 常量,所以按项显式放过(上在宏外面不算,lint 是对展开后的 item 报的)。 +// clippy 1.99 的 `missing_const_for_thread_local` 对**已经是** `const { … }` 的初始化也报 +// (换过三种写法都照报,属于误报;复审 r1 已复核并确认,仓内 `pages/appearance/probe.rs` 等 +// 两处已有同样的 `allow`)。初始化确实已是常量,所以按项显式放过(上在宏外面不算,lint 是对 +// 展开后的 item 报的)。 thread_local! { /// 进程内唯一时间轴(见模块文档「为什么是 thread-local」)。 #[allow(clippy::missing_const_for_thread_local)] @@ -88,12 +102,30 @@ pub fn needs_frames() -> bool { PUMP.with(|pump| pump.borrow().is_animating()) } -/// 页主体入场:`(screen, detail)` 任一标识变了就跑对应槽;返回**取整后的位移**。 +/// 页主体的内容**标识**:**每条消息处理完**调一次(`App::sync_motion`),标识变了就此起跑。 /// -/// 每帧调一次(`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)) +/// `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 是整段空闲时长 ⇒ 动画一帧走完,用户只看到一次偏移闪跳。 +/// +/// 起跑时**顺带把帧基准重置到「现在」**:空闲期没有帧,`LAST_FRAME` 是几分钟前的值, +/// 不重置的话第一帧仍会按整段空闲时长结算(第二道兜底是 [`pump::MAX_STEP_MS`])。 +pub fn observe(screen: Option, detail: Option<&str>) { + let started = PUMP.with(|pump| pump.borrow_mut().observe(screen, detail)); + if started { + LAST_FRAME.with(|last| last.set(Some(Instant::now()))); + } +} + +/// 当前入场位移(**只采样、无副作用**):`pages::render` 每帧调一次。 +pub fn sample() -> (f32, f32) { + PUMP.with(|pump| pump.borrow().offsets()) } /// 推进时间轴(`App::on_frame` 每帧调一次)。 @@ -112,17 +144,15 @@ pub fn advance() { /// 把入场位移套到树上。 /// -/// 位移为 `(0, 0)` 时**原样返回**(不额外套一层容器):静息态与「没有动效」的代码路径逐像素相同, -/// 这也是既有截图基线一张都不用重出的原因。 +/// **总是**套这一层(位移为 `(0,0)` 时只是 `padding = 0`):它是树的**根**,而 iced 的 +/// `Tree::diff` 按 widget tag 判等——根 tag 一换就重建整棵子树。若只在动画期间才套, +/// 动画起止两侧会各重建一次,页面内的滚动位置 / 焦点会被重置(复审 r1 提示的风险)。 +/// 常驻一层的代价是布局上多一个 `Fill` 容器(零内距时逐像素相同,出图基线已复验)。 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) + container(element.into()) .width(Length::Fill) .height(Length::Fill) .padding(Padding { diff --git a/crates/app-iced/src/components/motion/pump.rs b/crates/app-iced/src/components/motion/pump.rs index f0bac6a..749efee 100644 --- a/crates/app-iced/src/components/motion/pump.rs +++ b/crates/app-iced/src/components/motion/pump.rs @@ -22,13 +22,28 @@ use std::collections::BTreeMap; use unlucky_design::motion::{DurationToken, Easing, MotionScale}; use unlucky_design::spacing::Space; -/// 逐帧推进的**最短间隔**(16 ms ≈ 60 次/秒)。 +/// 逐帧**推进时间轴**的最短间隔(16 ms ≈ 60 次/秒)。 /// -/// 为什么需要它:`window::frames()` 的到达频率由合成器/渲染器决定,软件渲染实测能到几百帧/秒。 -/// 帧数本身不贵(贵的是整窗重绘,那由「订阅只在动画期间开着」管住),但**每帧都改布局**会让 -/// 页面每毫秒 reflow 一次。所以时间轴按 16 ms 结算:到得再快也只推进这么多次。 +/// ⚠️ **这只卡「采样与布局推进」,不卡整窗重绘。** 两个频率必须分开看: +/// +/// | 什么 | 频率 | 由谁决定 | +/// |---|---|---| +/// | 时间轴推进 / 位移采样(本常量) | ≤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)。 +/// +/// 为什么需要它:`advance` 的 dt 来自「上一帧到现在」,而空闲期根本没有帧——醒来的第一帧如果 +/// 按真实流逝(秒~分钟级)结算,`duration` 当场就被跨过 ⇒ 到期轨道被立刻删除 ⇒ 动画一帧走完 +/// (M192 r1 复审 P1-1 就是这么暴露的)。起点由 `motion::observe` 重置,这里再兜一道: +/// **任何一次采样最多只吃掉 4 帧的时长**,掉帧/被挂起时宁可把动画放慢,也不许把它一次性抹掉。 +pub const MAX_STEP_MS: f64 = FRAME_MS * 4.0; + /// 一个动效槽。 /// /// 槽是**同屏互斥**的:三个槽都是「页主体入场」的不同触发点,同时跑会让位移叠加 @@ -187,14 +202,14 @@ impl Pump { !self.tracks.is_empty() } - /// 起跑一个槽(关着动效时什么都不做——**不是**跑一遍 0 时长)。 + /// 起跑一个槽(关着动效时什么都不做——**不是**跑一遍 0 时长动画)。返回 `true` = 真的起跑了。 /// /// 三个槽**互斥**:它们都是「页主体入场」的不同触发点,同时跑会让位移叠加(首屏 + 换页 = /// 28px)。所以起跑新槽就丢掉旧的——语义上也对:新的一次内容变化取代上一次。 - pub fn play(&mut self, slot: Slot) { + pub fn play(&mut self, slot: Slot) -> bool { self.tracks.clear(); if self.scale.is_off() { - return; + return false; } // 钉住相位时轨道的起点固定在 0:进度于是恒等于「钉住的毫秒 / 时长」, // 与「谁在第几帧起跑」无关——这正是多帧序列图要的可复现性。 @@ -206,6 +221,7 @@ impl Pump { spec: spec(slot), }, ); + true } /// 采样:`0.0` = 刚起跑,`1.0` = 到位(**没有轨道就是到位**)。 @@ -227,29 +243,52 @@ impl Pump { self.pin.unwrap_or(self.now_ms) } - /// 页主体的入场位移:**每帧调一次**,标识变了才真的起跑。 + /// 页主体的内容**标识**变了就起跑对应槽;**必须在 update 侧调**(见 `App::sync_motion`)。 + /// + /// ## 为什么不能放在 `view` 里(M192 r1 复审 P1-1,这是本轮最重要的一行注释) + /// + /// 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 心跳)才打开——那一段里 + /// 页面已经带着位移画过一帧,然后就没了:用户看到的是一次偏移闪跳。 /// /// 三条判据(顺序即优先级): - /// 1. 第一帧 → [`Slot::Boot`](首屏也算一次「东西出现」); + /// 1. 第一次看到**归 pages 画的屏** → [`Slot::Boot`]; /// 2. 屏变了 → [`Slot::Screen`]; /// 3. 屏没变但详情变了 → [`Slot::Detail`]。 /// - /// 返回值按各槽剩余量求和后**取整**:整像素位移不会让文字在动画期间发虚, - /// 末帧三个槽都是 `1.0` ⇒ 恒为 `(0, 0)` ⇒ 与静息的布局逐像素相同。 - pub fn enter(&mut self, screen: u64, detail: Option<&str>) -> (f32, f32) { + /// `screen == None`(账户页 / AI 独立页:它们不走 `pages::render`)**只记标识、不起跑**—— + /// 否则会白跑 300 ms 的帧却没人采样它;而「从这些页回到某一屏」因为标识从 `None` 变回 + /// `Some`,仍然会跑换页那一档。 + /// + /// 返回 `true` = 真的起跑了新轨道(调用方要重置帧基准,见 [`MAX_STEP_MS`])。 + pub fn observe(&mut self, screen: Option, detail: Option<&str>) -> bool { + let mut started = false; 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); + if screen.is_some() { + started = self.play(Slot::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); } - self.screen = Some(screen); + self.screen = screen; if self.detail.as_deref() != detail { self.detail = detail.map(str::to_owned); } + started + } + /// 页主体的入场位移(**只采样,无副作用**):`pages::render` 每帧调一次。 + /// + /// 三个槽的剩余量求和后**取整**:整像素位移不会让文字在动画期间发虚;末帧全部 `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 { @@ -263,16 +302,18 @@ impl Pump { /// 推进时间轴。返回 `true` = 这一帧真的推进了(进度变了 ⇒ 要重画)。 /// - /// 三步:①不满 [`FRAME_MS`] 就不动(帧上限)②到期的轨道**当场删除**(这是「空闲不烧帧」的 - /// 唯一出口:删掉最后一个轨道之后 [`is_animating`](Self::is_animating) 立刻为假,下一拍订阅表 - /// 里就不再有 `window::frames()`)③钉住相位时时间轴不前进(取证专用)。 + /// 四步:①不满 [`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() { self.pending_ms = 0.0; return false; } if dt_ms.is_finite() && dt_ms > 0.0 { - self.pending_ms += dt_ms; + // ②:上限不是「省事」,是正确性——stale 的帧基准(空闲整段)会把轨道一帧杀完。 + self.pending_ms += dt_ms.min(MAX_STEP_MS); } if self.pending_ms < FRAME_MS { return false; diff --git a/crates/app-iced/src/components/motion/tests.rs b/crates/app-iced/src/components/motion/tests.rs index 647850f..13e09af 100644 --- a/crates/app-iced/src/components/motion/tests.rs +++ b/crates/app-iced/src/components/motion/tests.rs @@ -1,6 +1,8 @@ //! 帧泵的守卫(M192)。全部用**本地** `Pump` 摆时间轴:不起窗口、不等真实时间,所以不会假绿。 +//! +//! 接线级的守卫(真消息 / 真订阅表)在 `app/subscription.rs` 的 `mod tests`:这一组只管算术。 -use super::pump::{offset, spec, Pump, Slot, FRAME_MS}; +use super::pump::{offset, spec, Pump, Slot, FRAME_MS, MAX_STEP_MS}; use super::*; /// 标准档、没有钉相位的泵(出厂态是 `NONE`,见 [`Pump::new`])。 @@ -39,7 +41,7 @@ fn every_slot_spec_is_a_registered_token_pair() { ms ); // 缓动必须是真的曲线(端点 = 0/1) - assert!((spec.easing.sample(0.0)).abs() < 1.0e-6); + 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); @@ -67,7 +69,7 @@ fn every_slot_spec_is_a_registered_token_pair() { #[test] fn a_finished_animation_stops_asking_for_frames() { let mut pump = standard(); - pump.play(Slot::Screen); + assert!(pump.play(Slot::Screen), "起跑必须成功"); assert!(pump.is_animating(), "起跑后必须有轨道(订阅帧的唯一理由)"); settle(&mut pump); @@ -76,6 +78,7 @@ fn a_finished_animation_stops_asking_for_frames() { "动画结束必须**当场**退订(占着轨道 = 空闲烧帧)" ); assert_eq!(pump.progress(Slot::Screen), 1.0, "结束即到位"); + assert_eq!(pump.offsets(), (0.0, 0.0), "结束即归位"); // 再推进也不该有任何采样(订阅表里已经没有帧了) let before = pump.samples(); @@ -85,15 +88,16 @@ fn a_finished_animation_stops_asking_for_frames() { assert_eq!(pump.samples(), before, "空闲态一次采样都不该发生"); } -/// **帧间隔上限**:帧到得再快,时间轴也只按 16 ms 结算(≈60 次/秒)。 +/// **采样频率上限**:帧到得再快,时间轴也只按 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 的合成器) + // 200 帧、每帧 1 ms(模拟 1000 fps 的渲染器) let mut advanced = 0; for _ in 0..200 { if pump.advance(1.0) { @@ -117,6 +121,38 @@ fn frames_are_capped_at_sixty_per_second() { ); } +/// **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() { @@ -146,11 +182,13 @@ fn none_scale_never_starts_a_track() { assert!(pump.scale().is_off()); // 首屏 + 换页 + 换详情:三次状态变化都不许留下轨道 - assert_eq!(pump.enter(1, None), (0.0, 0.0), "关掉动效必须直接落终态"); + assert!(!pump.observe(Some(1), None), "关掉动效时不许起跑"); + assert_eq!(pump.offsets(), (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.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 { @@ -170,6 +208,7 @@ fn switching_to_none_mid_flight_settles_immediately() { 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 渲染兜底)真的把时长减半:同样的时间轴推进,轨道更早结束。 @@ -193,67 +232,94 @@ fn half_scale_finishes_in_half_the_frames() { // ------------------------------------------------------------------ 触发判据 -/// 三个槽的触发优先级:首帧 Boot → 换屏 Screen → 同屏换详情 Detail。 +/// 三个槽的触发优先级:首屏 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!(pump.observe(Some(1), None), "首帧要跑 Boot 槽"); + let (dx, dy) = pump.offsets(); assert_eq!(dx, 0.0); - assert!(dy > 0.0, "首屏应有一个向下的入场位移"); - assert!(pump.is_animating(), "首帧要跑 Boot 槽"); + assert_eq!(dy, offset(Slot::Boot).1, "首屏应有一个向下的入场位移"); assert_eq!( pump.progress(Slot::Screen), 1.0, "首帧不该顺带起跑 Screen 槽" ); settle(&mut pump); - assert_eq!(pump.enter(1, None), (0.0, 0.0), "标识没变 ⇒ 不动"); + assert!(!pump.observe(Some(1), None), "标识没变 ⇒ 不起跑"); + assert_eq!(pump.offsets(), (0.0, 0.0)); // 换屏:Screen(纵向) - let (dx, dy) = pump.enter(2, None); + assert!(pump.observe(Some(2), None)); + let (dx, dy) = pump.offsets(); assert_eq!(dx, 0.0); - assert!(dy > 0.0); + assert_eq!(dy, offset(Slot::Screen).1); 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!(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_eq!(pump.enter(2, Some("instance-7")), (0.0, 0.0)); + 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(); - let boot_dy = offset(Slot::Boot).1; - assert_eq!(pump.enter(1, None).1, boot_dy, "首帧从 Boot 的最大位移开始"); + assert_eq!( + pump.offsets().1, + 0.0, + "还没 observe 时没有位移(采样是纯函数)" + ); + pump.observe(Some(1), None); + assert_eq!(pump.offsets().1, offset(Slot::Boot).1); // 首屏还没跑完就换页:位移必须是 Screen 那一档,而不是两档相加 - let (_, dy) = pump.enter(2, None); + pump.observe(Some(2), None); assert_eq!( - dy, + pump.offsets().1, 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")); + pump.observe(Some(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.observe(Some(3), Some("instance-9"))); + assert_eq!(pump.offsets(), (0.0, 0.0)); } assert!(!pump.is_animating(), "重复绘制不该起新轨道"); } diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index 3828af7..ec4f1fe 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -253,27 +253,10 @@ fn render<'a>( 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()) + // M192:主体入场(首屏 / 换页 / 列表↔详情)。**这里只采样**——「标识变了就起跑」在 update 侧 + // (`App::sync_motion`):iced 先算订阅表后建视图,在 view 里插轨道当拍订阅不上(复审 P1-1)。 + // 静息时位移恒为 `(0, 0)`,`shifted` 常驻的那一层零内距,与没有动效时逐像素相同。 + motion::shifted(tree, motion::sample()) } /// 整窗(**harness 专用**):标题栏留白带 + 导航轨 + 当前路由的页面。