Merge branch 'feat/m193-animmotion-wire-design-motion-token'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
This commit is contained in:
commit
5505ea0f91
11 files changed
+1477
-8
No files matched your search
@@ -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`])。
|
||||
|
||||
@@ -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,250 @@ impl App {
|
||||
.is_some_and(|deadline| Instant::now() < deadline)
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
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::<Vec<_>>()
|
||||
.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<std::path::PathBuf>) {
|
||||
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"]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -53,6 +56,7 @@ mod dialog;
|
||||
mod dialog_fixture;
|
||||
mod download;
|
||||
mod instances;
|
||||
mod motion_sync;
|
||||
mod page_messages;
|
||||
mod settings;
|
||||
mod update_action;
|
||||
@@ -70,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<Message> {
|
||||
let task = self.route_message(message);
|
||||
self.sync_motion();
|
||||
task
|
||||
}
|
||||
|
||||
/// 把一条消息派发到它的落点(`update` 的正文;拆出来只为让 [`App::sync_motion`] 收尾)。
|
||||
fn route_message(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::Boot => self.on_boot(),
|
||||
Message::WindowReady(id) => {
|
||||
@@ -183,6 +198,10 @@ impl App {
|
||||
fn on_boot(&mut self) -> Task<Message> {
|
||||
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,27 +261,66 @@ 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 过期、观察窗关闭)之后没有别的东西会叫醒
|
||||
/// app,心跳链必须一直在,才能把低频时钟接回去。忙碌期间它只是每秒醒一次、什么都不做。
|
||||
fn on_heartbeat(&mut self) -> Task<Message> {
|
||||
// 动效时间轴也吃这一拍:窗口被遮挡 / 最小化 / 合成器不送绘制时,帧一个都不来,而
|
||||
// 「有轨道」又让下面的 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()
|
||||
}
|
||||
|
||||
/// 一帧画完:①够 33 ms 就推进一拍 presenter ②截图模式在第 [`CAPTURE_AT_FRAME`] 帧出图。
|
||||
/// 一帧画完:①动效时间轴推进 ②够 33 ms 就推进一拍 presenter ③截图模式在第
|
||||
/// [`CAPTURE_AT_FRAME`] 帧出图。
|
||||
///
|
||||
/// 时间闸门按**真实流逝时间**判(不是数帧):掉帧不会把游戏启动进度拖慢,
|
||||
/// 也不会在慢机器上把 1.5 s 的下载阶段拉成 4 s。
|
||||
fn on_frame(&mut self) -> Task<Message> {
|
||||
self.frames += 1;
|
||||
|
||||
// M192:动效时间轴。**每帧都调**,帧率上限在帧泵内部(16 ms 一次结算),所以忙碌态的
|
||||
// 近千 fps 不会变成近千次重排;动画结束后帧泵自动清空,下一拍订阅表就不再有 `frames()`。
|
||||
motion::advance();
|
||||
// 出图取证(`ULL_MOTION_FIXTURE`):前两帧各做一次**真的**状态变化。
|
||||
// 没有这步,截图通道每一帧画的都是同一屏,页面切换 / 列表↔详情的入场就没有可拍的相位。
|
||||
// `ULL_MOTION_FIXTURE_AT`(真机空闲取证)在的时候**不跑**这一支:两条取证路互斥,
|
||||
// 否则第 1 帧就把页面换成目标页,等 `_AT` 的心跳再跳同一个目标 = 标识没变 = 没有动效。
|
||||
if self.frames <= 2 && boot_env::env_motion_fixture_at().is_none() {
|
||||
self.motion_fixture();
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
let elapsed = now.saturating_duration_since(self.last_tick);
|
||||
if elapsed >= TICK {
|
||||
|
||||
@@ -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,104 @@ pub(super) fn env_start_route() -> Option<Route> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 动效档位与相位钉住(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=<ms>`:把动画时钟钉在某一毫秒(多帧序列图取证)。
|
||||
///
|
||||
/// 语义与 `ULL_APPEARANCE_*` / `ULL_DIALOG_FIXTURE` 一致:只影响这一次进程。没有它,第 3 帧落在
|
||||
/// 动画的哪个相位取决于这台机器的渲染速度 —— 那样「0% / 50% / 100% 三张图」是抓阄抓出来的。
|
||||
pub(super) fn env_motion_pin() -> Option<f64> {
|
||||
std::env::var("ULL_MOTION_AT")
|
||||
.ok()?
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.filter(|ms| ms.is_finite() && *ms >= 0.0)
|
||||
}
|
||||
|
||||
/// `ULL_MOTION_FIXTURE`:第 1 帧之后**真的**做一次状态变化(多帧序列图靠它)。
|
||||
///
|
||||
/// 为什么要制造变化:页面主体的入场只在「内容标识变了」时起跑(`motion::observe`),而截图通道
|
||||
/// 从第一帧起画的就是同一页——不制造一次真实的路由变化,就没有可拍的动效。
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) enum MotionFixture {
|
||||
/// 跳到一个一级页(走「换页」那一档)。
|
||||
Route(Route),
|
||||
/// 「列表 → 详情」:先回列表(第 1 帧),再点进第一条实例(第 2 帧)。
|
||||
Detail,
|
||||
}
|
||||
|
||||
/// `ULL_MOTION_FIXTURE=<instances|download|logs|settings|content|detail>`。
|
||||
pub(super) fn env_motion_fixture() -> Option<MotionFixture> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
/// `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<u32> {
|
||||
std::env::var("ULL_MOTION_FIXTURE_AT")
|
||||
.ok()?
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
.filter(|beats| *beats > 0)
|
||||
}
|
||||
|
||||
/// 把启动那一拍的**事实**写进启动器自己的日志(M187 / r1 复审 P2-4)。
|
||||
///
|
||||
/// ## 为什么这一拍只能「先记、后落」
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Screen>`,这里
|
||||
/// 再折成 `Option<u64>` 给帧泵用。
|
||||
fn screen_key(route: &Route) -> Option<u64> {
|
||||
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<Option<u64>> = [
|
||||
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 画");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
//! **动效帧泵**(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 ≈ 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 续订/ 心跳
|
||||
//! 天然不会双订阅——它们共享同一个布尔。
|
||||
//!
|
||||
//! ## `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()` 顶成真」的假红)。
|
||||
//!
|
||||
//! 仓内已有同样的先例(`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` |
|
||||
//!
|
||||
//! 页面**拿不到**时长/缓动/弹簧参数——那是 [`spec`] 的事。
|
||||
//!
|
||||
//! 位移走 `container` 的 `padding`:动画期间内容比容器少几像素高,末帧归零后与静息**逐像素相同**。
|
||||
//! 不做逐像素 α(D27:CPU 档零运行时半透明)也不做模糊(同上),所以两个渲染后端出图一致。
|
||||
|
||||
mod pump;
|
||||
|
||||
// 本 mission 里这五个只被测试与文档引用(产品路径只经 `observe`/`sample`/`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 { … }` 的初始化也报
|
||||
// (换过三种写法都照报,属于误报;复审 r1 已复核并确认,仓内 `pages/appearance/probe.rs` 等
|
||||
// 两处已有同样的 `allow`)。初始化确实已是常量,所以按项显式放过(上在宏外面不算,lint 是对
|
||||
// 展开后的 item 报的)。
|
||||
thread_local! {
|
||||
/// 进程内唯一时间轴(见模块文档「为什么是 thread-local」)。
|
||||
#[allow(clippy::missing_const_for_thread_local)]
|
||||
static PUMP: RefCell<pump::Pump> = const { RefCell::new(pump::Pump::new()) };
|
||||
/// 上次收到帧的时刻(`advance` 用它把「帧到达」换算成流逝毫秒)。
|
||||
#[allow(clippy::missing_const_for_thread_local)]
|
||||
static LAST_FRAME: Cell<Option<Instant>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
/// 定档:`on_boot` 调一次(截图档给 `NONE`,CPU 档给 `HALF`,见 `app/update/boot_env.rs`)。
|
||||
pub fn configure(scale: MotionScale, pin: Option<f64>) {
|
||||
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())
|
||||
}
|
||||
|
||||
/// 页主体的内容**标识**:**每条消息处理完**调一次(`App::sync_motion`),标识变了就此起跑。
|
||||
///
|
||||
/// `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<u64>, 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` 每帧调一次)。
|
||||
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)` 时只是 `padding = 0`):它是树的**根**,而 iced 的
|
||||
/// `Tree::diff` 按 widget tag 判等——根 tag 一换就重建整棵子树。若只在动画期间才套,
|
||||
/// 动画起止两侧会各重建一次,页面内的滚动位置 / 焦点会被重置(复审 r1 提示的风险)。
|
||||
/// 常驻一层的代价是布局上多一个 `Fill` 容器(零内距时逐像素相同,出图基线已复验)。
|
||||
pub fn shifted<'a, M: 'a>(
|
||||
element: impl Into<Element<'a, M>>,
|
||||
(dx, dy): (f32, f32),
|
||||
) -> Element<'a, M> {
|
||||
container(element.into())
|
||||
.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;
|
||||
@@ -0,0 +1,348 @@
|
||||
//! 帧泵的**纯逻辑**:槽 / 规格表 / 时间轴 / 进度采样。
|
||||
//!
|
||||
//! 这一层不认识 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 次/秒)。
|
||||
///
|
||||
/// ⚠️ **这只卡「采样与布局推进」,不卡整窗重绘。** 两个频率必须分开看:
|
||||
///
|
||||
/// | 什么 | 频率 | 由谁决定 |
|
||||
/// |---|---|---|
|
||||
/// | 时间轴推进 / 位移采样(本常量) | ≤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;
|
||||
|
||||
/// 一个动效槽。
|
||||
///
|
||||
/// 槽是**同屏互斥**的:三个槽都是「页主体入场」的不同触发点,同时跑会让位移叠加
|
||||
/// (首屏 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<f64>,
|
||||
now_ms: f64,
|
||||
pending_ms: f64,
|
||||
tracks: BTreeMap<Slot, Track>,
|
||||
screen: Option<u64>,
|
||||
detail: Option<String>,
|
||||
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<f64>) {
|
||||
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 时长动画)。返回 `true` = 真的起跑了。
|
||||
///
|
||||
/// 三个槽**互斥**:它们都是「页主体入场」的不同触发点,同时跑会让位移叠加(首屏 + 换页 =
|
||||
/// 28px)。所以起跑新槽就丢掉旧的——语义上也对:新的一次内容变化取代上一次。
|
||||
pub fn play(&mut self, slot: Slot) -> bool {
|
||||
self.tracks.clear();
|
||||
if self.scale.is_off() {
|
||||
return false;
|
||||
}
|
||||
// 钉住相位时轨道的起点固定在 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),
|
||||
},
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// 采样:`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)
|
||||
}
|
||||
|
||||
/// 页主体的内容**标识**变了就起跑对应槽;**必须在 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. 第一次看到**归 pages 画的屏** → [`Slot::Boot`];
|
||||
/// 2. 屏变了 → [`Slot::Screen`];
|
||||
/// 3. 屏没变但详情变了 → [`Slot::Detail`]。
|
||||
///
|
||||
/// `screen == None`(账户页 / AI 独立页:它们不走 `pages::render`)**只记标识、不起跑**——
|
||||
/// 否则会白跑 300 ms 的帧却没人采样它;而「从这些页回到某一屏」因为标识从 `None` 变回
|
||||
/// `Some`,仍然会跑换页那一档。
|
||||
///
|
||||
/// 返回 `true` = 真的起跑了新轨道(调用方要重置帧基准,见 [`MAX_STEP_MS`])。
|
||||
pub fn observe(&mut self, screen: Option<u64>, detail: Option<&str>) -> bool {
|
||||
let mut started = false;
|
||||
if !self.painted {
|
||||
self.painted = true;
|
||||
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 = 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 {
|
||||
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`] 就不动(采样上限)②**单次最多吃 [`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 {
|
||||
// ②:上限不是「省事」,是正确性——stale 的帧基准(空闲整段)会把轨道一帧杀完。
|
||||
self.pending_ms += dt_ms.min(MAX_STEP_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<Slot> = 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
//! 帧泵的守卫(M192)。全部用**本地** `Pump` 摆时间轴:不起窗口、不等真实时间,所以不会假绿。
|
||||
//!
|
||||
//! 接线级的守卫(真消息 / 真订阅表)在 `app/subscription.rs` 的 `mod tests`:这一组只管算术。
|
||||
|
||||
use super::pump::{offset, spec, Pump, Slot, FRAME_MS, MAX_STEP_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();
|
||||
assert!(pump.play(Slot::Screen), "起跑必须成功");
|
||||
assert!(pump.is_animating(), "起跑后必须有轨道(订阅帧的唯一理由)");
|
||||
|
||||
settle(&mut pump);
|
||||
assert!(
|
||||
!pump.is_animating(),
|
||||
"动画结束必须**当场**退订(占着轨道 = 空闲烧帧)"
|
||||
);
|
||||
assert_eq!(pump.progress(Slot::Screen), 1.0, "结束即到位");
|
||||
assert_eq!(pump.offsets(), (0.0, 0.0), "结束即归位");
|
||||
|
||||
// 再推进也不该有任何采样(订阅表里已经没有帧了)
|
||||
let before = pump.samples();
|
||||
for _ in 0..60 {
|
||||
assert!(!pump.advance(FRAME_MS), "没有轨道时推进必须返回 false");
|
||||
}
|
||||
assert_eq!(pump.samples(), before, "空闲态一次采样都不该发生");
|
||||
}
|
||||
|
||||
/// **采样频率上限**:帧到得再快,时间轴也只按 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 的渲染器)
|
||||
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()
|
||||
);
|
||||
}
|
||||
|
||||
/// **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() {
|
||||
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!(!pump.observe(Some(1), None), "关掉动效时不许起跑");
|
||||
assert_eq!(pump.offsets(), (0.0, 0.0), "关掉动效必须直接落终态");
|
||||
assert!(!pump.is_animating());
|
||||
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 {
|
||||
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);
|
||||
assert_eq!(pump.offsets(), (0.0, 0.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)
|
||||
assert!(pump.observe(Some(1), None), "首帧要跑 Boot 槽");
|
||||
let (dx, dy) = pump.offsets();
|
||||
assert_eq!(dx, 0.0);
|
||||
assert_eq!(dy, offset(Slot::Boot).1, "首屏应有一个向下的入场位移");
|
||||
assert_eq!(
|
||||
pump.progress(Slot::Screen),
|
||||
1.0,
|
||||
"首帧不该顺带起跑 Screen 槽"
|
||||
);
|
||||
settle(&mut pump);
|
||||
assert!(!pump.observe(Some(1), None), "标识没变 ⇒ 不起跑");
|
||||
assert_eq!(pump.offsets(), (0.0, 0.0));
|
||||
|
||||
// 换屏:Screen(纵向)
|
||||
assert!(pump.observe(Some(2), None));
|
||||
let (dx, dy) = pump.offsets();
|
||||
assert_eq!(dx, 0.0);
|
||||
assert_eq!(dy, offset(Slot::Screen).1);
|
||||
assert!(pump.progress(Slot::Screen) < 1.0, "换屏要跑 Screen 槽");
|
||||
settle(&mut pump);
|
||||
|
||||
// 同屏换详情:Detail(横向)
|
||||
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!(!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();
|
||||
assert_eq!(
|
||||
pump.offsets().1,
|
||||
0.0,
|
||||
"还没 observe 时没有位移(采样是纯函数)"
|
||||
);
|
||||
pump.observe(Some(1), None);
|
||||
assert_eq!(pump.offsets().1, offset(Slot::Boot).1);
|
||||
|
||||
// 首屏还没跑完就换页:位移必须是 Screen 那一档,而不是两档相加
|
||||
pump.observe(Some(2), None);
|
||||
assert_eq!(
|
||||
pump.offsets().1,
|
||||
offset(Slot::Screen).1,
|
||||
"新的一次内容变化应取代上一次(叠加会让位移变成两档之和)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 同一屏同一详情重复绘制**永远不会**重新起跑(否则每帧都在重启动画 = 永远停不下来)。
|
||||
#[test]
|
||||
fn repeated_paints_never_restart_the_animation() {
|
||||
let mut pump = standard();
|
||||
pump.observe(Some(3), Some("instance-9"));
|
||||
let frames = settle(&mut pump);
|
||||
assert!(frames > 0);
|
||||
|
||||
for _ in 0..30 {
|
||||
assert!(!pump.observe(Some(3), Some("instance-9")));
|
||||
assert_eq!(pump.offsets(), (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(), "钉住的相位不会自然结束(取证专用)");
|
||||
}
|
||||
@@ -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,19 @@ 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:主体入场(首屏 / 换页 / 列表↔详情)。**这里只采样**——「标识变了就起跑」在 update 侧
|
||||
// (`App::sync_motion`):iced 先算订阅表后建视图,在 view 里插轨道当拍订阅不上(复审 P1-1)。
|
||||
// 静息时位移恒为 `(0, 0)`,`shifted` 常驻的那一层零内距,与没有动效时逐像素相同。
|
||||
motion::shifted(tree, motion::sample())
|
||||
}
|
||||
|
||||
/// 整窗(**harness 专用**):标题栏留白带 + 导航轨 + 当前路由的页面。
|
||||
|
||||
Reference in new issue
Block a user