- 新轨道两条:components/motion/pump/{rows,progress}.rs(纯算术,连拍 advance(dt) 可判)
· rows:两条清单(画出来的顺序 / 真值)+ 逐行可见度;退场行钉在原位、走完才移除
· progress:真实读数一跳,画出来的值沿 short4 追上去;到 100% 一次脉冲(0→1→0,不循环)
- 接线:pages/download/model.rs::from_snapshot(scope 内唯一 update 侧入口)
· 行清单 observe → 差分;退场行的数据进 leaving 池(否则退场行画不出来)
· 进度 observe(key = 版本 id)
- 视图:rows/list.rs 换成 keyed::Column(按 id 做连续匹配)+ 逐行 revealed(裁切框收放)
+ 同色遮罩淡出;install.rs 画插值后的值 + 收束
- MotionScale::NONE:不插轨道、不留幽灵,值严格等于最终值、零采样
- 顺手:rows/tests.rs 重新挂上 mod(此前一直没编译),i18n 扫描补两个新文件
判据:motion::tests::{rows,progress}、rows/motion_tests(Tree::diff 后未变行状态盒不变)、
出图 DevDocs/artifacts/m305(probe.py 量行距与墨色)
377 lines
16 KiB
Rust
377 lines
16 KiB
Rust
//! 帧泵的**纯逻辑**:时间轴 / 页面切换轨道 / 模态轨道 / 采样。
|
||
//!
|
||
//! 这一层不认识 iced、不认识线程、不读环境变量——那些都在 [`super`]。分开的理由很实际:
|
||
//! 「帧间隔上限」「结束后必须清空」这两条守卫是**算术性质**,用本地 `Pump` 直接摆时间轴就能判死,
|
||
//! 不必起窗口、不必等真实时间(那两条最容易写成「看起来在跑」的假绿)。
|
||
//!
|
||
//! ## 四条轨道(M256 / M257 / M300):页面两腿 + 模态进出 + 两条宽度轨,各走各的时间轴(互不替代)
|
||
//!
|
||
//! | 轨道 | 触发 | 结束 |
|
||
//! |---|---|---|
|
||
//! | 页面 | 内容标识变了([`Pump::observe`]) | 两腿里长的那条走完 |
|
||
//! | 模态 | 弹窗开 / 关([`Pump::observe_modal`] / [`Pump::begin_modal_close`]) | 进场转 [`Modal::Open`](**不再要帧**)、退场转 [`Modal::Hidden`] |
|
||
//! | 右栗折叠(M257) | 右栗档位变([`Pump::observe_rail`],拆出档走 [`Pump::snap_rail`]) | 主进度走完一条腿(`medium1` / `short4`) |
|
||
//! | 左轨折叠(M300) | 导航轨折叠档变([`Pump::observe_nav`],启动按落盘档对表走 [`Pump::snap_nav`]) | 同上(**同一张** [`super::transition::rail_spec`]) |
|
||
//!
|
||
//! D31:时长/缓动是**表**([`super::transition`]),页面只许按表取——它只能说「我要换页了」。
|
||
|
||
use unlucky_design::motion::{DurationToken, MotionScale};
|
||
|
||
mod modal;
|
||
mod progress;
|
||
mod rail;
|
||
mod rows;
|
||
|
||
use super::transition::{
|
||
enter_offset, exit_offset, modal_spec, page_spec, Kind, PageFrame, PageSpec,
|
||
};
|
||
pub(crate) use modal::Modal;
|
||
pub(crate) use progress::{Progress, ProgressFrame};
|
||
pub(crate) use rail::RailTrack;
|
||
pub(crate) use rows::{RowFrame, Rows};
|
||
|
||
/// 逐帧**推进时间轴**的最短间隔(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`) |
|
||
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;
|
||
|
||
/// 一条正在跑的页面轨道。
|
||
#[derive(Debug, Clone, Copy)]
|
||
struct PageTrack {
|
||
kind: Kind,
|
||
spec: PageSpec,
|
||
start_ms: f64,
|
||
}
|
||
|
||
/// 帧泵:一条时间轴 + 最多一条页面轨道 + 一个模态状态机。
|
||
///
|
||
/// **默认是关的**(`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,
|
||
page: Option<PageTrack>,
|
||
modal: Modal,
|
||
/// 右栗折叠轨道(M257):`Some` = 宽度正在 `48 ⇄ 展开档` 之间走。
|
||
rail: Option<RailTrack>,
|
||
/// 右栗的目标档(`true` = 展开)。没有轨道时进度就是它的终值。
|
||
rail_open: bool,
|
||
/// 左轨(导航轨)折叠轨道(M300):与右栗**并列**的另一条宽度轨(`64 ⇄ 88`)。
|
||
nav: Option<RailTrack>,
|
||
/// 左轨的目标档(`true` = 展开 88)。出厂展开;启动时按落盘档由 [`Pump::snap_nav`] 对表。
|
||
nav_open: bool,
|
||
/// 列表行增删的轨道(M305):两条清单 + 在跑的可见度轨道,见 `pump/rows.rs`。
|
||
rows: Rows,
|
||
/// 进度插值 + 收束脉冲(M305),见 `pump/progress.rs`。
|
||
progress: Progress,
|
||
/// 退场走完了、等调用方真正关掉那个框([`Pump::take_modal_closed`] 取走一次)。
|
||
close_pending: bool,
|
||
screen: Option<u64>,
|
||
/// 该屏在**导航轨**上的次序(`shell::nav::NavItem::ALL` 下标;`None` = 不在轨上)。
|
||
rank: Option<u8>,
|
||
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,
|
||
page: None,
|
||
modal: Modal::Hidden,
|
||
rail: None,
|
||
rail_open: false,
|
||
nav: None,
|
||
nav_open: true,
|
||
rows: Rows::new(),
|
||
progress: Progress::new(),
|
||
close_pending: false,
|
||
screen: None,
|
||
rank: 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.rank = None;
|
||
self.detail = None;
|
||
self.pending_ms = 0.0;
|
||
self.modal = Modal::Hidden;
|
||
// 定档时右栗按出厂档(收起)复位:定档之后的第一帧画的就是 48,不会先画一次展开态。
|
||
self.rail = None;
|
||
self.rail_open = false;
|
||
// 左轨出厂是**展开**(`UiPrefs::nav_collapsed` 的出厂值 false)。落盘过折叠档的会话由
|
||
// `app/update/motion_sync.rs` 在定档之后 `snap_nav` 对表(那里的判据是「没有轨在跑」)。
|
||
self.nav = None;
|
||
self.nav_open = true;
|
||
// M305:定档即复位两条新轨道。行清单**不**在这里猜(第一次观察只对表,见 `rows.rs`)。
|
||
self.reset_rows();
|
||
self.reset_progress();
|
||
self.close_pending = false;
|
||
if scale.is_off() {
|
||
// 关掉动效 = 直接落终态:把在跑的轨道清掉,而不是让它跑一遍 0 时长。
|
||
self.page = None;
|
||
}
|
||
}
|
||
|
||
/// 当前档位。
|
||
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.page.is_some()
|
||
|| self.rail.is_some()
|
||
|| self.nav.is_some()
|
||
|| self.rows.is_animating()
|
||
|| self.progress.is_animating()
|
||
|| matches!(self.modal, Modal::Opening { .. } | Modal::Closing { .. })
|
||
}
|
||
|
||
/// 左轨的宽度轨道是否在跑(`motion_sync` 用它判「该不该按落盘档对表」)。
|
||
pub fn nav_animating(&self) -> bool {
|
||
self.nav.is_some()
|
||
}
|
||
|
||
/// 轨道起点的口径:钉住相位时恒为 0(进度只取决于钉住值,与"谁在第几帧起跑"无关)。
|
||
fn start_now(&self) -> f64 {
|
||
if self.pin.is_some() {
|
||
0.0
|
||
} else {
|
||
self.now_ms
|
||
}
|
||
}
|
||
|
||
/// 当前动画时刻(钉住相位时恒为钉住值)。
|
||
fn elapsed_ms(&self) -> f64 {
|
||
self.pin.unwrap_or(self.now_ms)
|
||
}
|
||
|
||
/// 把某一档时长换算成实际毫秒(`MotionScale` 是唯一缩放口)。
|
||
fn leg_ms(&self, duration: DurationToken) -> f64 {
|
||
f64::from(self.scale.ms(duration))
|
||
}
|
||
|
||
// ------------------------------------------------------------ 页面切换
|
||
|
||
/// 起跑一次页面切换(关着动效时什么都不做——**不是**跑一遍 0 时长动画)。
|
||
///
|
||
/// 新轨道**取代**上一条:同一时刻只有一次页面切换(否则位移会叠加)。
|
||
pub fn play_page(&mut self, kind: Kind) -> bool {
|
||
self.page = None;
|
||
if self.scale.is_off() {
|
||
return false;
|
||
}
|
||
let spec = page_spec(kind);
|
||
self.page = Some(PageTrack {
|
||
kind,
|
||
spec,
|
||
start_ms: self.start_now(),
|
||
});
|
||
true
|
||
}
|
||
|
||
/// 当前页面轨道(测试与日志用;产品路径只看采样出来的位移)。
|
||
#[allow(dead_code)]
|
||
pub fn kind(&self) -> Option<Kind> {
|
||
self.page.map(|track| track.kind)
|
||
}
|
||
|
||
/// 页主体的这一帧采样(**只采样,无副作用**)。
|
||
///
|
||
/// 两条腿各走各的时长:进场读 `enter.duration`,退场读 `exit.duration`(更短)。
|
||
/// `exiting` 在退场腿走完的那一帧就变假 ⇒ 调用方不再画旧页(不长留一份幽灵树)。
|
||
pub fn page_frame(&self) -> PageFrame {
|
||
let Some(track) = &self.page else {
|
||
return PageFrame::STILL;
|
||
};
|
||
let elapsed = self.elapsed_ms() - track.start_ms;
|
||
let enter_t = leg_fraction(elapsed, self.leg_ms(track.spec.enter.duration));
|
||
let exit_t = leg_fraction(elapsed, self.leg_ms(track.spec.exit.duration));
|
||
PageFrame {
|
||
enter: enter_offset(
|
||
track.spec.enter_from,
|
||
track.spec.enter.easing.sample(enter_t),
|
||
),
|
||
exit: exit_offset(track.spec.exit_to, track.spec.exit.easing.sample(exit_t)),
|
||
exiting: exit_t < 1.0,
|
||
}
|
||
}
|
||
|
||
/// 内容**标识**变了就起跑对应的一档;**必须在 update 侧调**(见 `App::sync_motion`)。
|
||
///
|
||
/// 三条判据(顺序即优先级):
|
||
/// 1. 第一次看到**归 pages 画的屏** → [`Kind::Boot`];
|
||
/// 2. 屏变了 → [`Kind::Peer`],方向由**屏在导航表里的先后**决定(更靠后的屏 = 向右推进);
|
||
/// 3. 屏没变但详情变了 → 列表→详情 = [`Kind::Push`](深一层)、详情→列表 = [`Kind::Pop`],
|
||
/// 两个不同的详情之间横跳按同级处理。
|
||
///
|
||
/// `screen == None`(账户页 / AI 独立页:它们不走 `pages::render`)**只记标识、不起跑**。
|
||
///
|
||
/// 返回 `true` = 真的起跑了新轨道(调用方要重置帧基准,见 [`MAX_STEP_MS`])。
|
||
pub fn observe(&mut self, screen: Option<u64>, rank: Option<u8>, detail: Option<&str>) -> bool {
|
||
let kind = if !self.painted {
|
||
self.painted = true;
|
||
screen.map(|_| Kind::Boot)
|
||
} else if self.screen != screen {
|
||
match (self.screen, screen, self.rank, rank) {
|
||
// 左右方向 = **用户在导航轨上看到的次序**(`rank`),不是内部屏键的大小:
|
||
// 两者顺序不同(`Screen::ALL` 里 Settings 在 Logs 前,导航轨里 Logs 在 Settings
|
||
// 前)⇒ 用屏键会把 Logs↔Settings 镜像。同 rank(Download / Content 共用导航项)
|
||
// 时退到屏键比大小,方向仍是"向后 / 向前"。
|
||
(Some(old_key), Some(new_key), Some(old_rank), Some(new_rank)) => {
|
||
Some(Kind::Peer {
|
||
forward: (new_rank, new_key) > (old_rank, old_key),
|
||
})
|
||
}
|
||
// 从「不归 pages 画」的屏回来:没有旧页可退,按首屏那一档的入场方向。
|
||
(_, Some(_), _, _) => Some(Kind::Boot),
|
||
_ => None,
|
||
}
|
||
} else if screen.is_some() && self.detail.as_deref() != detail {
|
||
Some(match (self.detail.is_some(), detail.is_some()) {
|
||
(false, true) => Kind::Push,
|
||
(true, false) => Kind::Pop,
|
||
// 两个不同的详情之间横跳:深度没变,按同级换内容处理。
|
||
_ => Kind::Peer { forward: true },
|
||
})
|
||
} else {
|
||
None
|
||
};
|
||
|
||
self.screen = screen;
|
||
self.rank = rank;
|
||
if self.detail.as_deref() != detail {
|
||
self.detail = detail.map(str::to_owned);
|
||
}
|
||
kind.is_some_and(|kind| self.play_page(kind))
|
||
}
|
||
|
||
// ------------------------------------------------------------ 模态
|
||
|
||
// ------------------------------------------------------------ 时间轴
|
||
|
||
/// 推进时间轴。返回 `true` = 这一帧真的推进了(进度变了 ⇒ 要重画)。
|
||
///
|
||
/// 四步:①不满 [`FRAME_MS`] 就不动(采样上限)②**单次最多吃 [`MAX_STEP_MS`]**(掉帧 /
|
||
/// 被挂起 / 空闲醒来的第一帧都不许一次性跨过时长)③到期的轨道**当场删除**(这是「空闲不烧帧」
|
||
/// 的唯一出口)④钉住相位时时间轴不前进(取证专用)。
|
||
pub fn advance(&mut self, dt_ms: f64) -> bool {
|
||
if !self.is_animating() {
|
||
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;
|
||
self.expire();
|
||
true
|
||
}
|
||
|
||
/// 到期轨道当场清掉(③)。
|
||
fn expire(&mut self) {
|
||
let now = self.elapsed_ms();
|
||
if let Some(track) = self.page {
|
||
let enter = self.leg_ms(track.spec.enter.duration);
|
||
let exit = self.leg_ms(track.spec.exit.duration);
|
||
if now - track.start_ms >= enter.max(exit) {
|
||
self.page = None;
|
||
}
|
||
}
|
||
if let Some(track) = self.rail {
|
||
if now - track.start_ms >= track.duration_ms {
|
||
self.rail = None;
|
||
}
|
||
}
|
||
if let Some(track) = self.nav {
|
||
if now - track.start_ms >= track.duration_ms {
|
||
self.nav = None;
|
||
}
|
||
}
|
||
// M305:行与进度各自清理(各自的「到期即删」出口,见两个子模块)。
|
||
self.expire_rows(now);
|
||
self.expire_progress(now);
|
||
let spec = modal_spec();
|
||
match self.modal {
|
||
Modal::Opening { start_ms } if now - start_ms >= self.leg_ms(spec.enter.duration) => {
|
||
self.modal = Modal::Open
|
||
}
|
||
Modal::Closing { start_ms, .. }
|
||
if now - start_ms >= self.leg_ms(spec.exit.duration) =>
|
||
{
|
||
self.modal = Modal::Hidden;
|
||
self.close_pending = true;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `elapsed / duration` 夹到 `0..=1`(`duration <= 0` ⇒ 直接到位,不产生 NaN)。
|
||
fn leg_fraction(elapsed: f64, duration: f64) -> f32 {
|
||
if duration <= 0.0 {
|
||
return 1.0;
|
||
}
|
||
(elapsed / duration).clamp(0.0, 1.0) as f32
|
||
}
|
||
|
||
impl Default for Pump {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|