Files
Unlucky-Launcher/crates/app-iced/src/components/motion/pump/mod.rs
T
AskaEth ae33674293 feat(motion): M305 —— 列表增删(错位淡入/退场后才移除)+ 进度插值(到点一次收束)
- 新轨道两条: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 量行距与墨色)
2026-11-13 21:42:18 +08:00

377 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 帧泵的**纯逻辑**:时间轴 / 页面切换轨道 / 模态轨道 / 采样。
//!
//! 这一层不认识 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()
}
}