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 量行距与墨色)
This commit is contained in:
1 parent
b9536c5833
commit
ae33674293
24 files changed
+2175
-153
No files matched your search
@@ -87,5 +87,5 @@ mod tests;
|
|||||||
|
|
||||||
pub use dialog::{dialog, scrimmed, Dialog};
|
pub use dialog::{dialog, scrimmed, Dialog};
|
||||||
pub use empty_state::{empty_state, EmptyState};
|
pub use empty_state::{empty_state, EmptyState};
|
||||||
pub use progress::{circular, linear, Progress};
|
pub use progress::{circular, linear, linear_with, Progress};
|
||||||
pub use snackbar::{snackbar, Placement, Snackbar};
|
pub use snackbar::{snackbar, Placement, Snackbar};
|
||||||
@@ -193,8 +193,30 @@ pub(crate) fn circular_colors(ui: &Ui) -> (iced::Color, iced::Color) {
|
|||||||
/// 轨道宽 `Fill`(由宿主决定),指示条按占比分到 `FillPortion` 上——**不需要知道轨道像素宽**,
|
/// 轨道宽 `Fill`(由宿主决定),指示条按占比分到 `FillPortion` 上——**不需要知道轨道像素宽**,
|
||||||
/// 所以 50% 在任何宿主宽度下都精确是半条。
|
/// 所以 50% 在任何宿主宽度下都精确是半条。
|
||||||
pub fn linear<'a, M: 'a>(ui: &'a Ui, progress: Progress) -> Element<'a, M> {
|
pub fn linear<'a, M: 'a>(ui: &'a Ui, progress: Progress) -> Element<'a, M> {
|
||||||
|
linear_with(ui, progress, 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 线性进度条 + **到达收束**(M305):`settle`(`0..=1`)是「刚到 100%」那一下的脉冲强度。
|
||||||
|
///
|
||||||
|
/// `settle = 0` 时与 [`linear`] **逐像素相同**(同一个函数、同一条取色),所以静息帧与末帧
|
||||||
|
/// 都不受这一版影响。脉冲只改**指示条的颜色**:`Primary` ⇄ `InversePrimary` 之间按 `settle`
|
||||||
|
/// 预合成插值(两端都是实色,D27 的「不给渲染器半透明」不破)——不改厚度、不改圆角,
|
||||||
|
/// 于是它**不引起任何重排**(厚度改 1px 会把下面一行文字顶下去,那是像素级跳动)。
|
||||||
|
///
|
||||||
|
/// 为什么是「颜色亮一下」而不是「条涨一点」:100% 时指示条已经占满轨道,再涨没有余量;
|
||||||
|
/// 而颜色的强调在深浅两个主题下都读得出来(`InversePrimary` 是同一强调色族的另一端)。
|
||||||
|
pub fn linear_with<'a, M: 'a>(ui: &'a Ui, progress: Progress, settle: f32) -> Element<'a, M> {
|
||||||
let geometry = LinearGeometry::of(ui);
|
let geometry = LinearGeometry::of(ui);
|
||||||
let (track_color, active_color) = linear_colors(ui);
|
let (track_color, base_active) = linear_colors(ui);
|
||||||
|
let active_color = if settle > 0.0 {
|
||||||
|
super::super::motion::mix(
|
||||||
|
ui.scheme().get(Role::Primary),
|
||||||
|
ui.scheme().get(Role::InversePrimary),
|
||||||
|
settle.clamp(0.0, 1.0),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
base_active
|
||||||
|
};
|
||||||
|
|
||||||
let mut bar = row![]
|
let mut bar = row![]
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
//! **颜色预合成**(M305):把 α 在令牌层算掉,渲染器只收实色。
|
||||||
|
//!
|
||||||
|
//! ## 这条纪律是什么(D27)
|
||||||
|
//!
|
||||||
|
//! 「CPU 档零半透明」:整窗重绘里每多一层半透明,软件渲染就多一遍混合。仓内唯一的 α 出口是
|
||||||
|
//! **令牌层预合成**(`c.with_alpha(α).over(底)`,见 `interaction::disabled` 与
|
||||||
|
//! `dialog/view.rs::backdrop_color_at`):产物**恒不透明**。
|
||||||
|
//! `interaction/mod.rs` 的用例把这条钉成硬线(「为了淡出改用 `Color { a: 0.5 }`」直接红)。
|
||||||
|
//!
|
||||||
|
//! 本模块只剩一件工具:[`mix`]——两个**不透明**色之间按进度插值,用于「进度条到达 100% 时那一下
|
||||||
|
//! 强调收束」(`feedback/progress.rs::linear_with`)。它不改几何、不改厚度,所以**不引起重排**。
|
||||||
|
//!
|
||||||
|
//! ## 为什么这里没有「整行淡出」
|
||||||
|
//!
|
||||||
|
//! 列表行的淡出走的是 `motion/reveal.rs` 的收尾遮罩(原因写在那里:`Element<'a>` 借 `&'a Ui`,
|
||||||
|
//! 视图函数里现造的「淡化版 Ui」撑不到返回;把它泄漏成 `'static` 会让每帧每行泄漏一份 49 色方案)。
|
||||||
|
//! 行内容因此**永远逐像素实色绘制**,只有裁切框与那一层遮罩在变。
|
||||||
|
|
||||||
|
use unlucky_design::color::Color;
|
||||||
|
|
||||||
|
/// 两个**不透明**颜色之间按 `t` 的插值(预合成口径,同 [`faded`] 的纪律)。
|
||||||
|
///
|
||||||
|
/// 用在「进度条收束」这类没有背景色可叠的地方:`to.with_alpha(t).over(from)` 得到的是
|
||||||
|
/// 「把 `to` 按 t 盖在 `from` 上」的**实色**,产物恒不透明(D27);端点 `t = 0` / `1`
|
||||||
|
/// 逐位等于入参(不是「差不多相等」)。返回 iced 色值(组件层的取色口径)。
|
||||||
|
pub fn mix(from: Color, to: Color, t: f32) -> iced::Color {
|
||||||
|
if t <= 0.0 {
|
||||||
|
return crate::theme::to_iced(from);
|
||||||
|
}
|
||||||
|
if t >= 1.0 {
|
||||||
|
return crate::theme::to_iced(to);
|
||||||
|
}
|
||||||
|
crate::theme::to_iced(to.with_alpha(t).over(from))
|
||||||
|
}
|
||||||
@@ -49,7 +49,11 @@
|
|||||||
//! 模态的**缩放**走 `renderer.with_transformation`(两个后端都实现,见 [`scaled`])。
|
//! 模态的**缩放**走 `renderer.with_transformation`(两个后端都实现,见 [`scaled`])。
|
||||||
|
|
||||||
mod clip;
|
mod clip;
|
||||||
|
mod fade;
|
||||||
|
pub mod progress;
|
||||||
mod pump;
|
mod pump;
|
||||||
|
mod reveal;
|
||||||
|
pub mod rows;
|
||||||
mod transition;
|
mod transition;
|
||||||
mod zoom;
|
mod zoom;
|
||||||
|
|
||||||
@@ -60,12 +64,16 @@ pub use pump::{Pump, FRAME_MS, MAX_STEP_MS};
|
|||||||
// 规格表与采样形状全部经这里导出:页面只拿得到「这一帧怎么画」,拿不到「用哪一档、多久」。
|
// 规格表与采样形状全部经这里导出:页面只拿得到「这一帧怎么画」,拿不到「用哪一档、多久」。
|
||||||
// (`page_spec`/`Leg`/`*Spec` 只被测试与文档引用,产品路径只用 `Kind` 与采样形状。)
|
// (`page_spec`/`Leg`/`*Spec` 只被测试与文档引用,产品路径只用 `Kind` 与采样形状。)
|
||||||
pub use clip::clipped;
|
pub use clip::clipped;
|
||||||
|
pub use fade::mix;
|
||||||
|
pub use reveal::revealed;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use transition::{
|
pub use transition::{
|
||||||
enter_offset, exit_offset, modal_scale, modal_spec, page_spec, rail_spec, Kind, Leg,
|
enter_offset, exit_offset, modal_scale, modal_spec, page_spec, rail_spec, Kind, Leg,
|
||||||
ModalFrame, ModalSpec, PageFrame, PageSpec, RailSpec, MODAL_SCALE_FROM,
|
ModalFrame, ModalSpec, PageFrame, PageSpec, RailSpec, MODAL_SCALE_FROM,
|
||||||
};
|
};
|
||||||
pub use zoom::scaled;
|
pub use zoom::scaled;
|
||||||
|
// M305:列表增删 + 进度插值。页面只拿得到「这一帧怎么画」,轨道与时长都在泵里
|
||||||
|
// (两个入口都是 `pub mod`:`motion::rows::observe/frame`、`motion::progress::observe/value`)。
|
||||||
|
|
||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
@@ -148,6 +156,12 @@ pub fn needs_frames() -> bool {
|
|||||||
PUMP.with(|pump| pump.borrow().is_animating()) || super::interaction::layer::is_animating()
|
PUMP.with(|pump| pump.borrow().is_animating()) || super::interaction::layer::is_animating()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 起跑之后把帧基准重置到「现在」(与 `observe` / `observe_modal` 同一个动作,抽出来给 M305 的
|
||||||
|
/// 两条轨道共用:空闲期没有帧,不重置的话第一帧会按整段空闲时长结算——`MAX_STEP_MS` 是第二道兜底)。
|
||||||
|
fn reset_frame_clock() {
|
||||||
|
LAST_FRAME.with(|last| last.set(Some(Instant::now())));
|
||||||
|
}
|
||||||
|
|
||||||
/// 页主体的内容**标识**:**每条消息处理完**调一次(`App::sync_motion`),标识变了就此起跑。
|
/// 页主体的内容**标识**:**每条消息处理完**调一次(`App::sync_motion`),标识变了就此起跑。
|
||||||
///
|
///
|
||||||
/// `screen` 是 `pages::Screen` 的稳定键(`None` = 这一屏不归 pages 画:账户页 / AI 独立页),
|
/// `screen` 是 `pages::Screen` 的稳定键(`None` = 这一屏不归 pages 画:账户页 / AI 独立页),
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//! 进度插值的**公开采样口**(M305):页面只拿得到「这一帧这根条到几成」。
|
||||||
|
//!
|
||||||
|
//! ## 谁起跑 / 谁采样
|
||||||
|
//!
|
||||||
|
//! | 侧 | 函数 | 干什么 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | update | [`observe`] | 报「哪一根条 + 真实进度」;差分与起跑在泵里 |
|
||||||
|
//! | view | [`frame`] | 这一帧画出来的值(**追值**,不是整数值硬跳)+ 收束强度 |
|
||||||
|
//!
|
||||||
|
//! **为什么 `key` 与 `target` 两个参数都要传**:泵记的是「上次报的 key + 目标」,视图报的是
|
||||||
|
//! 这一帧模型里的「key + 目标」。两者不一致 ⇒ 有人改了模型却没走 [`observe`](截图 harness、
|
||||||
|
//! 单测直接构造模型、或将来某个新入口忘了接线)⇒ 那时 [`value`] **直接返回目标**:
|
||||||
|
//! 宁可无动效,也不许把界面卡在中间值(同 `motion::rail` 的口径)。
|
||||||
|
//!
|
||||||
|
//! ## `MotionScale::NONE`
|
||||||
|
//!
|
||||||
|
//! [`observe`] 不插轨道 ⇒ 值严格等于目标、收束恒为 0 ⇒ `needs_frames()` 不会
|
||||||
|
//! 因为进度变化而为真。
|
||||||
|
|
||||||
|
use super::pump::ProgressFrame;
|
||||||
|
use super::PUMP;
|
||||||
|
|
||||||
|
/// **update 侧**:报「哪一根条(`key`)+ 真实进度」。返回 `true` = 真的起跑了追值。
|
||||||
|
pub fn observe(key: &str, target: f32) -> bool {
|
||||||
|
let started = PUMP.with(|pump| pump.borrow_mut().observe_progress(key, target));
|
||||||
|
if started {
|
||||||
|
super::reset_frame_clock();
|
||||||
|
}
|
||||||
|
started
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一帧的完整进度采样(`value` + `settle`):页面一次取完,避免两次进 thread-local。
|
||||||
|
pub fn frame(key: &str, target: f32) -> ProgressFrame {
|
||||||
|
PUMP.with(|pump| {
|
||||||
|
let pump = pump.borrow();
|
||||||
|
ProgressFrame {
|
||||||
|
value: pump.progress_value(key, target),
|
||||||
|
settle: pump.progress_settle(key, target),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -18,13 +18,17 @@
|
|||||||
use unlucky_design::motion::{DurationToken, MotionScale};
|
use unlucky_design::motion::{DurationToken, MotionScale};
|
||||||
|
|
||||||
mod modal;
|
mod modal;
|
||||||
|
mod progress;
|
||||||
mod rail;
|
mod rail;
|
||||||
|
mod rows;
|
||||||
|
|
||||||
use super::transition::{
|
use super::transition::{
|
||||||
enter_offset, exit_offset, modal_spec, page_spec, Kind, PageFrame, PageSpec,
|
enter_offset, exit_offset, modal_spec, page_spec, Kind, PageFrame, PageSpec,
|
||||||
};
|
};
|
||||||
pub(crate) use modal::Modal;
|
pub(crate) use modal::Modal;
|
||||||
|
pub(crate) use progress::{Progress, ProgressFrame};
|
||||||
pub(crate) use rail::RailTrack;
|
pub(crate) use rail::RailTrack;
|
||||||
|
pub(crate) use rows::{RowFrame, Rows};
|
||||||
|
|
||||||
/// 逐帧**推进时间轴**的最短间隔(16 ms ≈ 60 次/秒)。
|
/// 逐帧**推进时间轴**的最短间隔(16 ms ≈ 60 次/秒)。
|
||||||
///
|
///
|
||||||
@@ -73,6 +77,10 @@ pub struct Pump {
|
|||||||
nav: Option<RailTrack>,
|
nav: Option<RailTrack>,
|
||||||
/// 左轨的目标档(`true` = 展开 88)。出厂展开;启动时按落盘档由 [`Pump::snap_nav`] 对表。
|
/// 左轨的目标档(`true` = 展开 88)。出厂展开;启动时按落盘档由 [`Pump::snap_nav`] 对表。
|
||||||
nav_open: bool,
|
nav_open: bool,
|
||||||
|
/// 列表行增删的轨道(M305):两条清单 + 在跑的可见度轨道,见 `pump/rows.rs`。
|
||||||
|
rows: Rows,
|
||||||
|
/// 进度插值 + 收束脉冲(M305),见 `pump/progress.rs`。
|
||||||
|
progress: Progress,
|
||||||
/// 退场走完了、等调用方真正关掉那个框([`Pump::take_modal_closed`] 取走一次)。
|
/// 退场走完了、等调用方真正关掉那个框([`Pump::take_modal_closed`] 取走一次)。
|
||||||
close_pending: bool,
|
close_pending: bool,
|
||||||
screen: Option<u64>,
|
screen: Option<u64>,
|
||||||
@@ -97,6 +105,8 @@ impl Pump {
|
|||||||
rail_open: false,
|
rail_open: false,
|
||||||
nav: None,
|
nav: None,
|
||||||
nav_open: true,
|
nav_open: true,
|
||||||
|
rows: Rows::new(),
|
||||||
|
progress: Progress::new(),
|
||||||
close_pending: false,
|
close_pending: false,
|
||||||
screen: None,
|
screen: None,
|
||||||
rank: None,
|
rank: None,
|
||||||
@@ -127,6 +137,9 @@ impl Pump {
|
|||||||
// `app/update/motion_sync.rs` 在定档之后 `snap_nav` 对表(那里的判据是「没有轨在跑」)。
|
// `app/update/motion_sync.rs` 在定档之后 `snap_nav` 对表(那里的判据是「没有轨在跑」)。
|
||||||
self.nav = None;
|
self.nav = None;
|
||||||
self.nav_open = true;
|
self.nav_open = true;
|
||||||
|
// M305:定档即复位两条新轨道。行清单**不**在这里猜(第一次观察只对表,见 `rows.rs`)。
|
||||||
|
self.reset_rows();
|
||||||
|
self.reset_progress();
|
||||||
self.close_pending = false;
|
self.close_pending = false;
|
||||||
if scale.is_off() {
|
if scale.is_off() {
|
||||||
// 关掉动效 = 直接落终态:把在跑的轨道清掉,而不是让它跑一遍 0 时长。
|
// 关掉动效 = 直接落终态:把在跑的轨道清掉,而不是让它跑一遍 0 时长。
|
||||||
@@ -155,6 +168,8 @@ impl Pump {
|
|||||||
self.page.is_some()
|
self.page.is_some()
|
||||||
|| self.rail.is_some()
|
|| self.rail.is_some()
|
||||||
|| self.nav.is_some()
|
|| self.nav.is_some()
|
||||||
|
|| self.rows.is_animating()
|
||||||
|
|| self.progress.is_animating()
|
||||||
|| matches!(self.modal, Modal::Opening { .. } | Modal::Closing { .. })
|
|| matches!(self.modal, Modal::Opening { .. } | Modal::Closing { .. })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,6 +342,9 @@ impl Pump {
|
|||||||
self.nav = None;
|
self.nav = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// M305:行与进度各自清理(各自的「到期即删」出口,见两个子模块)。
|
||||||
|
self.expire_rows(now);
|
||||||
|
self.expire_progress(now);
|
||||||
let spec = modal_spec();
|
let spec = modal_spec();
|
||||||
match self.modal {
|
match self.modal {
|
||||||
Modal::Opening { start_ms } if now - start_ms >= self.leg_ms(spec.enter.duration) => {
|
Modal::Opening { start_ms } if now - start_ms >= self.leg_ms(spec.enter.duration) => {
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
//! **进度插值**的轨道(M305):真实进度一跳,画出来的值沿一条腿追上去。
|
||||||
|
//!
|
||||||
|
//! ## 为什么不能直接画 `ratio`
|
||||||
|
//!
|
||||||
|
//! 后端给的是**离散读数**(`files_done / files_total`,一次跳 3% 或 20%),直接画就是
|
||||||
|
//! 「整数值硬跳」——M3E 的 PROGRESS 类动效要的是**连续推进**:值在两次事件之间也一直在走。
|
||||||
|
//!
|
||||||
|
//! | 项 | 值 | 出处 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | 追值一腿 | `short4` 200ms + `Standard` | `transition.rs::progress_spec`(单调、不回弹:进度倒退读起来像卡了) |
|
||||||
|
//! | 到达后收束 | `short3` 150ms 一次的脉冲 0→1→0 | 同上(**不循环**:循环会一直在闪) |
|
||||||
|
//!
|
||||||
|
//! ## 三条判据
|
||||||
|
//!
|
||||||
|
//! 1. **目标不符就给目标**(同 `motion::rail` 的口径):泵记的目标与调用方此刻给的不一致,
|
||||||
|
//! 说明有人改了值却没走 [`Pump::observe_progress`](截图 harness / 单测直接构造模型)——
|
||||||
|
//! 那时**宁可无动效,也不许把界面卡在中间值**。
|
||||||
|
//! 2. **`MotionScale::NONE` 严格等于目标**:不插轨道,`progress_value` 直接返回目标。
|
||||||
|
//! 3. **收束只跑一次**:脉冲走完就删([`Pump::expire_progress`]),下一次到达才重新排。
|
||||||
|
|
||||||
|
use super::super::transition::{progress_spec, Leg};
|
||||||
|
use super::{leg_fraction, Pump};
|
||||||
|
|
||||||
|
/// 一帧的进度采样(值与收束强度一起给:页面一次取完)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct ProgressFrame {
|
||||||
|
/// 这一帧画出来的值(`0..1`)。
|
||||||
|
pub value: f32,
|
||||||
|
/// 收束强度(`0` = 静息;只有到达 100% 那一下非零)。
|
||||||
|
pub settle: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProgressFrame {
|
||||||
|
/// 静息:值 = 目标、没有收束。产品路径由 [`Pump::progress_value`] 直接给目标、不经过它;
|
||||||
|
/// 留着当「没有轨道」的默认口径(同 `PageFrame::STILL`)。
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub const REST: Self = Self {
|
||||||
|
value: 0.0,
|
||||||
|
settle: 0.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一条正在跑的进度轨道。
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct ProgressTrack {
|
||||||
|
/// 起跑那一刻画出来的值(连跳时接着走 ⇒ 不跳变)。
|
||||||
|
from: f32,
|
||||||
|
/// 目标(后端此刻的真值)。
|
||||||
|
to: f32,
|
||||||
|
start_ms: f64,
|
||||||
|
duration_ms: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 到达 100% 之后的那一次收束(脉冲)。
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct Settle {
|
||||||
|
start_ms: f64,
|
||||||
|
duration_ms: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 进度条的时间轴:**一根条一份**(用 `key` 认:换了一个安装就是另一根条)。
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub(crate) struct Progress {
|
||||||
|
/// 这根条认谁(`None` = 还没见过任何目标)。
|
||||||
|
key: Option<String>,
|
||||||
|
track: Option<ProgressTrack>,
|
||||||
|
settle: Option<Settle>,
|
||||||
|
/// **上一次报上来的值**(没有轨道时「这一帧画多少」的答案)。
|
||||||
|
///
|
||||||
|
/// 为什么必须记它:第一次报一个 key 时没有「上一根」可追,画的就是目标本身;之后每一次跳变
|
||||||
|
/// 都从**这里**起跑。少了它,第二次报会把起跑点当成目标(等价于硬跳——复审就是这么抓到的)。
|
||||||
|
shown: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Progress {
|
||||||
|
/// 出厂态:没有条、没有轨道、没有收束。
|
||||||
|
pub(super) const fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
key: None,
|
||||||
|
track: None,
|
||||||
|
settle: None,
|
||||||
|
shown: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn clear(&mut self) {
|
||||||
|
self.key = None;
|
||||||
|
self.track = None;
|
||||||
|
self.settle = None;
|
||||||
|
self.shown = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn is_animating(&self) -> bool {
|
||||||
|
self.track.is_some() || self.settle.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 这一帧画出来的值(没有轨道 = 上一次报上来的值)。
|
||||||
|
fn value(&self, now_ms: f64) -> Option<f32> {
|
||||||
|
let Some(track) = self.track else {
|
||||||
|
return Some(self.shown);
|
||||||
|
};
|
||||||
|
let t = leg_fraction(now_ms - track.start_ms, track.duration_ms);
|
||||||
|
if t >= 1.0 {
|
||||||
|
Some(track.from + (track.to - track.from) * progress_spec().advance.easing.sample(t))
|
||||||
|
} else if t <= 0.0 {
|
||||||
|
Some(track.from)
|
||||||
|
} else {
|
||||||
|
Some(track.from + (track.to - track.from) * progress_spec().advance.easing.sample(t))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收束脉冲的强度(`0` = 没有;`1` = 脉冲顶点)。走完**当场删**(不循环)。
|
||||||
|
fn settle_amount(&self, now_ms: f64) -> f32 {
|
||||||
|
let Some(settle) = self.settle else {
|
||||||
|
return 0.0;
|
||||||
|
};
|
||||||
|
let t = leg_fraction(now_ms - settle.start_ms, settle.duration_ms);
|
||||||
|
if t >= 1.0 || t <= 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
// 0 → 1 → 0 的一次正弦脉冲:两端**严格**是 0(末帧与静息逐位相同)。
|
||||||
|
(std::f32::consts::PI * t).sin()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清掉走完的轨道。返回 `true` = **这一帧到达了 100%**,调用方要排一次收束。
|
||||||
|
fn expire(&mut self, now_ms: f64) -> bool {
|
||||||
|
if let Some(settle) = self.settle {
|
||||||
|
if now_ms - settle.start_ms >= settle.duration_ms {
|
||||||
|
self.settle = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(track) = self.track else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if now_ms - track.start_ms < track.duration_ms {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.track = None;
|
||||||
|
// 到达 1.0 才排收束(中途的一次跳变不是「完成」);已经在收束里就不重排。
|
||||||
|
track.to >= 1.0 && self.settle.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schedule_settle(&mut self, now_ms: f64, duration_ms: f64) {
|
||||||
|
self.settle = Some(Settle {
|
||||||
|
start_ms: now_ms,
|
||||||
|
duration_ms,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pump {
|
||||||
|
/// 进度目标变了:起跑一腿追上去。返回 `true` = 真的起跑了。
|
||||||
|
///
|
||||||
|
/// `key` 认「哪一根条」(安装是版本 id):换了 key 就**直接落到目标**(新安装不该从上一根的
|
||||||
|
/// 位置追过来)。**必须在 update 侧调**(`pages/download/model.rs::from_snapshot`)。
|
||||||
|
pub fn observe_progress(&mut self, key: &str, target: f32) -> bool {
|
||||||
|
let target = target.clamp(0.0, 1.0);
|
||||||
|
if self.progress.key.as_deref() != Some(key) {
|
||||||
|
// 换了一根条:不追值,直接落目标(收束也一起清掉)。
|
||||||
|
self.progress.clear();
|
||||||
|
self.progress.key = Some(key.to_owned());
|
||||||
|
self.progress.shown = target;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.progress
|
||||||
|
.track
|
||||||
|
.is_some_and(|track| (track.to - target).abs() < f32::EPSILON)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self.scale.is_off() {
|
||||||
|
// 关掉动效 = 值严格等于目标:清轨道、连收束也不排。
|
||||||
|
self.progress.track = None;
|
||||||
|
self.progress.settle = None;
|
||||||
|
self.progress.shown = target;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let now = self.elapsed_ms();
|
||||||
|
let from = self.progress.value(now).unwrap_or(target);
|
||||||
|
self.progress.shown = target;
|
||||||
|
// 到顶之后又变了(重装 / 回落):上一根的收束作废。
|
||||||
|
if self.progress.track.is_none() || target < 1.0 {
|
||||||
|
self.progress.settle = None;
|
||||||
|
}
|
||||||
|
self.progress.track = Some(self.progress_track(from, target));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn progress_track(&self, from: f32, to: f32) -> ProgressTrack {
|
||||||
|
let leg: Leg = progress_spec().advance;
|
||||||
|
ProgressTrack {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
start_ms: self.start_now(),
|
||||||
|
duration_ms: f64::from(self.scale.ms(leg.duration)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 这一帧画出来的进度值。`key` / `target` 与泵记的不一致 ⇒ **直接给 `target`**
|
||||||
|
/// (宁可无动效,也不许把界面卡在中间值)。
|
||||||
|
pub fn progress_value(&self, key: &str, target: f32) -> f32 {
|
||||||
|
let target = target.clamp(0.0, 1.0);
|
||||||
|
if self.progress.key.as_deref() != Some(key) {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
match self.progress.track {
|
||||||
|
// 目标对得上才采轨道;对不上(有人改了模型却没走 `observe`)就**给目标**。
|
||||||
|
Some(track) if (track.to - target).abs() < f32::EPSILON => {
|
||||||
|
self.progress.value(self.elapsed_ms()).unwrap_or(target)
|
||||||
|
}
|
||||||
|
_ => target,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 这一帧的收束脉冲强度(`0` = 静息;完成那一下才非零,**不循环**)。
|
||||||
|
pub fn progress_settle(&self, key: &str, target: f32) -> f32 {
|
||||||
|
if self.progress.key.as_deref() != Some(key) || target.clamp(0.0, 1.0) < 1.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
self.progress.settle_amount(self.elapsed_ms())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 进度轨道在不在跑(日志与断言用;`needs_frames` 走的是 `is_animating`)。
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn progress_animating(&self) -> bool {
|
||||||
|
self.progress.is_animating()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 到期清理(`Pump::expire` 调;收束的时长要按档缩放,所以排在这里而不是 [`Progress`] 里)。
|
||||||
|
pub(super) fn expire_progress(&mut self, now_ms: f64) {
|
||||||
|
if self.progress.expire(now_ms) {
|
||||||
|
let duration = f64::from(self.scale.ms(progress_spec().arrive.duration));
|
||||||
|
self.progress.schedule_settle(now_ms, duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 定档 / 复位(`Pump::configure` 用)。
|
||||||
|
pub fn reset_progress(&mut self) {
|
||||||
|
self.progress.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
//! **列表行增删**的轨道(M305):一行一个可见度,进/出各走一条腿,**退场走完才真移除**。
|
||||||
|
//!
|
||||||
|
//! ## 为什么需要「保留旧顺序」这张表
|
||||||
|
//!
|
||||||
|
//! 「删除项淡出后才真正移除」不是把行从列表里拿掉的时机问题,而是**顺序**问题:
|
||||||
|
//! 用户删掉第 3 行,如果这一帧就把它从 `column` 里摘掉,第 4 行起全部上移一格 —— 那就是
|
||||||
|
//! **整表瞬间重排闪跳**(任务里点名禁止)。所以本模块记两份清单:
|
||||||
|
//!
|
||||||
|
//! | 字段 | 是什么 | 谁读 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | `order` | **这一帧该画哪个顺序**(退场行还钉在它原来的位置) | [`Rows::drawn`] → 视图的列 |
|
||||||
|
//! | `current` | 数据模型**现在**给的行(真值) | 差分判据(进 / 出) |
|
||||||
|
//!
|
||||||
|
//! 退场行在 `order` 里活到它的退场腿走完([`Rows::expire`] 那一刻才从 `order` 删掉),
|
||||||
|
//! 而那一路它的可见度从 1 走到 0 ⇒ 高度也一路收到 0 ⇒ 下面那些行是**逐帧**滑上来的,
|
||||||
|
//! 收尾那一帧没有位移台阶。
|
||||||
|
//!
|
||||||
|
//! ## 为什么只有一个标量
|
||||||
|
//!
|
||||||
|
//! 一行的动效只由 `visibility: 0..1` 驱动:淡入淡出(色板预合成,见 `motion/fade.rs`)、
|
||||||
|
//! 高度(`motion/reveal.rs` 的裁切框)、滑动偏移([`RowFrame::offset`])**都从它算**。
|
||||||
|
//! 一个标量 ⇒ 进/出两个方向天然读**同一个时间源**([`Pump::elapsed_ms`]),不会出现
|
||||||
|
//! 「淡入走 200ms、高度走 150ms」这种两套时钟的错位。
|
||||||
|
//!
|
||||||
|
//! ## 关掉动效(`MotionScale::NONE`)
|
||||||
|
//!
|
||||||
|
//! [`Pump::observe_rows`] 在 `is_off()` 时**不插轨道、不留幽灵**:`order` 直接等于 `current`,
|
||||||
|
//! 于是 [`Rows::drawn`] 就是模型本身、[`Rows::frame`] 恒为 [`RowFrame::REST`]。换句话说:
|
||||||
|
//! 关掉动效 = 界面立刻是**最终状态**,而不是「跑一遍 0 时长动画」(那会在中途停一拍)。
|
||||||
|
|
||||||
|
use unlucky_design::motion::Easing;
|
||||||
|
|
||||||
|
use super::super::transition::{row_spec, Leg};
|
||||||
|
use super::{leg_fraction, Pump};
|
||||||
|
|
||||||
|
/// 一帧的行状态(视图只看这两个数,**不含任何时长**)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct RowFrame {
|
||||||
|
/// 可见度:`0` = 完全收起 / 融进背景,`1` = 静息。
|
||||||
|
pub visibility: f32,
|
||||||
|
/// 纵向偏移(px,正 = 向下)。入场从下方 `+slide` 浮上来,退场向 `-slide` 卷走。
|
||||||
|
pub offset: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RowFrame {
|
||||||
|
/// 静息:可见度 1、零偏移(`MotionScale::NONE` 与「没在动」恒为它)。
|
||||||
|
pub const REST: Self = Self {
|
||||||
|
visibility: 1.0,
|
||||||
|
offset: 0.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一条正在跑的行轨道(进 / 出共用一张形状,`from → to` 表达方向)。
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct RowTrack {
|
||||||
|
/// 起跑那一刻的可见度(退场中途又回列表时是中间值 ⇒ **不跳变**)。
|
||||||
|
from: f32,
|
||||||
|
/// 终点(进 = 1,出 = 0)。
|
||||||
|
to: f32,
|
||||||
|
/// 起跑时刻(含错位;钉住相位时恒为 0)。
|
||||||
|
start_ms: f64,
|
||||||
|
/// 这一腿的时长(已按 `MotionScale` 缩放)。
|
||||||
|
duration_ms: f64,
|
||||||
|
/// 这一腿的曲线。
|
||||||
|
easing: Easing,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RowTrack {
|
||||||
|
/// `elapsed` 毫秒之后的可见度(端点**逐位**等于 `from` / `to`)。
|
||||||
|
fn sample(&self, elapsed_ms: f64) -> f32 {
|
||||||
|
let t = leg_fraction(elapsed_ms, self.duration_ms);
|
||||||
|
if t >= 1.0 {
|
||||||
|
return self.to;
|
||||||
|
}
|
||||||
|
if t <= 0.0 {
|
||||||
|
return self.from;
|
||||||
|
}
|
||||||
|
self.from + (self.to - self.from) * self.easing.sample(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 这一腿走完了(错位的前置时间也算在内:`start_ms` 已经是「它自己的起跑时刻」)。
|
||||||
|
fn done(&self, now_ms: f64) -> bool {
|
||||||
|
now_ms - self.start_ms >= self.duration_ms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次观察的**错位口径**(所有行共用一份):起跑基准 + 一档间隔。
|
||||||
|
///
|
||||||
|
/// 错位档取 `min(这一行在新/旧顺序里的下标, max_stagger)`:列表很长时不许「越往后越等」。
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct Stagger {
|
||||||
|
start_ms: f64,
|
||||||
|
step_ms: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stagger {
|
||||||
|
/// 第 `index` 行的起跑时刻。
|
||||||
|
fn at(self, index: usize) -> f64 {
|
||||||
|
let slot = row_spec()
|
||||||
|
.max_stagger
|
||||||
|
.min(u8::try_from(index).unwrap_or(u8::MAX));
|
||||||
|
self.start_ms + f64::from(slot) * self.step_ms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列表行的两条清单 + 在跑的轨道(见模块文档)。
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub(crate) struct Rows {
|
||||||
|
/// 这一帧该画的顺序(退场行还钉在里面)。
|
||||||
|
order: Vec<String>,
|
||||||
|
/// 数据模型现在给的行(真值)。
|
||||||
|
current: Vec<String>,
|
||||||
|
/// 在跑的轨道(进 + 出)。`Vec` 而不是 `HashMap`:一屏几十行,线性查找比哈希分配便宜且稳。
|
||||||
|
tracks: Vec<(String, RowTrack)>,
|
||||||
|
/// 第一次观察只对表、不动画(否则**页面第一次出现**就是一整屏淡入)。
|
||||||
|
seeded: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rows {
|
||||||
|
/// 出厂态:两张空清单、没有轨道、没对过表。
|
||||||
|
pub(super) const fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
order: Vec::new(),
|
||||||
|
current: Vec::new(),
|
||||||
|
tracks: Vec::new(),
|
||||||
|
seeded: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 数据真的变了没有(真值口径:跟 `current` 比)。
|
||||||
|
fn differs(&self, ids: &[&str]) -> bool {
|
||||||
|
self.current.len() != ids.len()
|
||||||
|
|| self
|
||||||
|
.current
|
||||||
|
.iter()
|
||||||
|
.zip(ids)
|
||||||
|
.any(|(current, id)| current != id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 现在的可见度(没有轨道 = 1.0)。
|
||||||
|
fn visibility_of(&self, id: &str) -> f32 {
|
||||||
|
self.tracks
|
||||||
|
.iter()
|
||||||
|
.find(|(tracked, _)| tracked == id)
|
||||||
|
.map_or(1.0, |(_, track)| track.from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 换一条轨道(同 id 覆盖:中途反向就接着当前进度走)。
|
||||||
|
fn retarget(
|
||||||
|
&mut self,
|
||||||
|
id: &str,
|
||||||
|
from: f32,
|
||||||
|
to: f32,
|
||||||
|
leg: Leg,
|
||||||
|
leg_ms: f64,
|
||||||
|
stagger: Stagger,
|
||||||
|
index: usize,
|
||||||
|
) {
|
||||||
|
let track = RowTrack {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
start_ms: stagger.at(index),
|
||||||
|
duration_ms: leg_ms,
|
||||||
|
easing: leg.easing,
|
||||||
|
};
|
||||||
|
match self.tracks.iter_mut().find(|(tracked, _)| tracked == id) {
|
||||||
|
Some(entry) => entry.1 = track,
|
||||||
|
None => self.tracks.push((id.to_owned(), track)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 这一帧该画的顺序:旧顺序为骨,新行按它在**新顺序里的前驱**插进去。
|
||||||
|
///
|
||||||
|
/// 三条性质(都有用例):①没变的行**位置逐帧不变**;②退场行还钉在原位;
|
||||||
|
/// ③进入的行落在它该在的地方(不是一律追加到末尾)。
|
||||||
|
pub(crate) fn drawn(&self) -> Vec<&str> {
|
||||||
|
let mut out: Vec<&str> = self.order.iter().map(String::as_str).collect();
|
||||||
|
for (index, id) in self.current.iter().enumerate() {
|
||||||
|
if out.contains(&id.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 找「新顺序里紧跟它、又已经在旧顺序里」的那一项:插在它前面。
|
||||||
|
let anchor = self.current[index + 1..]
|
||||||
|
.iter()
|
||||||
|
.find_map(|successor| out.iter().position(|drawn| drawn == successor));
|
||||||
|
match anchor {
|
||||||
|
Some(position) => out.insert(position, id.as_str()),
|
||||||
|
None => out.push(id.as_str()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 这一帧某一行画成什么样(**只采样、无副作用**)。
|
||||||
|
pub(crate) fn frame(&self, id: &str, now_ms: f64, slide: f32) -> RowFrame {
|
||||||
|
let Some((_, track)) = self.tracks.iter().find(|(tracked, _)| tracked == id) else {
|
||||||
|
return RowFrame::REST;
|
||||||
|
};
|
||||||
|
let visibility = track.sample(now_ms - track.start_ms);
|
||||||
|
// 进场:从下方浮上来(`+slide → 0`);退场:向**上**卷走(`0 → -slide`)。
|
||||||
|
let remaining = 1.0 - visibility;
|
||||||
|
let offset = if track.to > track.from {
|
||||||
|
slide * remaining
|
||||||
|
} else {
|
||||||
|
-slide * remaining
|
||||||
|
};
|
||||||
|
RowFrame { visibility, offset }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清掉走完的轨道,并把「退场也走完了」的行从画的顺序里删掉。
|
||||||
|
///
|
||||||
|
/// 这一步是**唯一**让退场行消失的地方——删早了就是闪跳,删晚了就是占着位置不动。
|
||||||
|
pub(super) fn expire(&mut self, now_ms: f64) {
|
||||||
|
self.tracks.retain(|(_, track)| !track.done(now_ms));
|
||||||
|
let current = std::mem::take(&mut self.current);
|
||||||
|
self.order.retain(|id| {
|
||||||
|
current.contains(id) || self.tracks.iter().any(|(tracked, _)| tracked == id)
|
||||||
|
});
|
||||||
|
self.current = current;
|
||||||
|
if self.tracks.is_empty() {
|
||||||
|
// 收尾归位:`order` 的「幽灵位」全部消失后两边应当逐项相同。
|
||||||
|
self.order.clone_from(&self.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 有没有轨道在跑(= 要不要订阅帧)。
|
||||||
|
pub(super) fn is_animating(&self) -> bool {
|
||||||
|
!self.tracks.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 定档 / 复位(`Pump::configure` 与测试的 `reset`)。
|
||||||
|
pub(super) fn clear(&mut self) {
|
||||||
|
self.order.clear();
|
||||||
|
self.current.clear();
|
||||||
|
self.tracks.clear();
|
||||||
|
self.seeded = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pump {
|
||||||
|
/// 列表的真值换了:差分出进 / 出两类行并起跑轨道。返回 `true` = 真的起跑了。
|
||||||
|
///
|
||||||
|
/// **必须在 update 侧调**(`pages/download/model.rs::from_snapshot`):iced 先算订阅表、
|
||||||
|
/// 后建视图,在 `view` 里起跑的轨道当拍订阅不上(M192 r1 复审 P1-1 的原始缺陷)。
|
||||||
|
///
|
||||||
|
/// 幂等:同一份清单再喂一次是空操作(视图重建 / 截图 harness 每帧重建模型都靠这条)。
|
||||||
|
pub fn observe_rows(&mut self, ids: &[&str]) -> bool {
|
||||||
|
if !self.rows.seeded {
|
||||||
|
self.rows.order = ids.iter().map(|id| (*id).to_owned()).collect();
|
||||||
|
self.rows.current = self.rows.order.clone();
|
||||||
|
self.rows.seeded = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if !self.rows.differs(ids) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self.scale.is_off() {
|
||||||
|
// 关掉动效 = 立刻落到最终状态:不留退场幽灵、也不插 0 时长轨道。
|
||||||
|
self.rows.order = ids.iter().map(|id| (*id).to_owned()).collect();
|
||||||
|
self.rows.current = self.rows.order.clone();
|
||||||
|
self.rows.tracks.clear();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let spec = row_spec();
|
||||||
|
let scale = self.scale;
|
||||||
|
let stagger = Stagger {
|
||||||
|
start_ms: self.start_now(),
|
||||||
|
step_ms: f64::from(scale.ms(spec.stagger)),
|
||||||
|
};
|
||||||
|
let enter_ms = f64::from(scale.ms(spec.enter.duration));
|
||||||
|
let exit_ms = f64::from(scale.ms(spec.exit.duration));
|
||||||
|
|
||||||
|
// ① 离开的:用**旧顺序**里的位置算错位(用户看到的原本次序)。
|
||||||
|
let leaving: Vec<(String, usize)> = self
|
||||||
|
.rows
|
||||||
|
.current
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, id)| !ids.contains(&id.as_str()))
|
||||||
|
.map(|(index, id)| (id.clone(), index))
|
||||||
|
.collect();
|
||||||
|
for (id, index) in leaving {
|
||||||
|
let from = self.rows.visibility_of(&id);
|
||||||
|
self.rows
|
||||||
|
.retarget(&id, from, 0.0, spec.exit, exit_ms, stagger, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 进入的(以及退场中途又回列表的):用**新顺序**里的位置算错位。
|
||||||
|
let arriving: Vec<(String, usize)> = ids
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, id)| {
|
||||||
|
let known = self.rows.current.iter().any(|current| current == id);
|
||||||
|
let retracting = self
|
||||||
|
.rows
|
||||||
|
.tracks
|
||||||
|
.iter()
|
||||||
|
.any(|(tracked, track)| tracked == id && track.to < 1.0);
|
||||||
|
(!known || retracting).then(|| ((*id).to_owned(), index))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let now = self.elapsed_ms();
|
||||||
|
for (id, index) in arriving {
|
||||||
|
// 退场中途回来(还有轨道):接着**当前**可见度走,不跳回 0;全新的行从 0 起。
|
||||||
|
let from = match self.rows.tracks.iter().find(|(tracked, _)| tracked == &id) {
|
||||||
|
Some((_, track)) => track.sample(now - track.start_ms),
|
||||||
|
None if self.rows.current.iter().any(|current| current == &id) => 1.0,
|
||||||
|
None => 0.0,
|
||||||
|
};
|
||||||
|
self.rows
|
||||||
|
.retarget(&id, from, 1.0, spec.enter, enter_ms, stagger, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.rows.current = ids.iter().map(|id| (*id).to_owned()).collect();
|
||||||
|
self.rows.is_animating()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 这一帧该画哪些行(顺序即画出来的顺序,退场行还钉在原位)。
|
||||||
|
pub fn rows_drawn(&self) -> Vec<&str> {
|
||||||
|
self.rows.drawn()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 定档 / 复位行的两条清单(`Pump::configure` 用)。
|
||||||
|
pub fn reset_rows(&mut self) {
|
||||||
|
self.rows.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 这一帧某一行画成什么样(视图逐行采样)。
|
||||||
|
pub fn row_frame(&self, id: &str) -> RowFrame {
|
||||||
|
self.rows
|
||||||
|
.frame(id, self.elapsed_ms(), row_spec().slide.dp())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 行轨道在不在跑(日志与断言用;`needs_frames` 走的是 `is_animating`)。
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn rows_animating(&self) -> bool {
|
||||||
|
self.rows.is_animating()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 到期清理(`Pump::expire` 调):轨道走完就删,退场行这时才真的从画的顺序里移除。
|
||||||
|
pub(super) fn expire_rows(&mut self, now_ms: f64) {
|
||||||
|
self.rows.expire(now_ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 进 / 出两条腿的时长(毫秒,已按档缩放)——测试与日志用,产品路径不读。
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn row_leg_ms(&self, entering: bool) -> f64 {
|
||||||
|
let spec = row_spec();
|
||||||
|
let leg = if entering { spec.enter } else { spec.exit };
|
||||||
|
f64::from(self.scale.ms(leg.duration))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
//! **纵向裁切容器**(M305):占的格子高可以逐帧变,**内容排版高不变**。
|
||||||
|
//!
|
||||||
|
//! ## 为什么需要它(与 `clip.rs` 是同一课,换了一个轴)
|
||||||
|
//!
|
||||||
|
//! 列表增删要「高度也一起收/放」:不收,退场行删掉的那一帧下面所有行整体上移一格 = **整表瞬间
|
||||||
|
//! 重排闪跳**(任务里点名禁止);收,就必须让一行的高从 44 走到 0。而直接把 44 喂给行内容会让
|
||||||
|
//! 它按「半行高」重新排版(文字折行 / 尾部按钮挤掉),那 150ms 里行内会「沸腾」——`clip.rs`
|
||||||
|
//! 的模块文档记的正是这件事(M257 的取证图里空态文案在过渡中折成两行)。
|
||||||
|
//!
|
||||||
|
//! 所以两件事拆开:
|
||||||
|
//!
|
||||||
|
//! | 谁 | 用多大的高 |
|
||||||
|
//! |---|---|
|
||||||
|
//! | 子内容的**排版** | `content_height`(整段过渡里不变 ⇒ 不重排) |
|
||||||
|
//! | 自己占的**格子**(= 裁切框) | `visible_height`(逐帧变,由行的可见度给) |
|
||||||
|
//!
|
||||||
|
//! ## 绘制
|
||||||
|
//!
|
||||||
|
//! 走 `Renderer::with_layer(bounds)`(tiny-skia 是 `push_clip`,wgpu 是同族的层栈),超出的部分
|
||||||
|
//! **画不出来**——「收起到 0」在像素上就是「一格都不占」,不需要半透明(D27 的单一口径)。
|
||||||
|
//! 淡出是另一件事,在 `motion/fade.rs`(色板预合成)。
|
||||||
|
//!
|
||||||
|
//! ## 与 `clip.rs` 的区别(为什么不再写一个通用件)
|
||||||
|
//!
|
||||||
|
//! | | `clip.rs` | 本文件 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | 轴 | 横向(宽度逐帧变) | 纵向(高度逐帧变) |
|
||||||
|
//! | 内容锚点 | **右**端对齐(右栗贴窗口右缘) | **上**端对齐 + 可给一个纵向偏移 |
|
||||||
|
//! | 子内容排版宽 | 显式 `content_width` | 吃父级给的最大宽(行本来就是整宽) |
|
||||||
|
//!
|
||||||
|
//! 两处的布局约束方向不同(一个是宽、一个是高),合成一个带 `enum Axis` 的控件会让两个调用点
|
||||||
|
//! 都得读对方的注释才知道自己在传什么——分开写反而更短。
|
||||||
|
//!
|
||||||
|
//! ## 已知局限(与 `clip.rs` 同一条,登记在报告里)
|
||||||
|
//!
|
||||||
|
//! **命中测试用排版矩形**:过渡期间一行的可点区域是它的**排版**高(内容高),不是裁切后的可视高。
|
||||||
|
//! 只在 150~200ms 内成立;末帧 `visible_height == content_height` 且偏移为 0 时两者重合,
|
||||||
|
//! 逐像素与静息相同。
|
||||||
|
|
||||||
|
use iced::advanced::layout::{self, Layout};
|
||||||
|
use iced::advanced::renderer;
|
||||||
|
use iced::advanced::widget::{tree, Tree};
|
||||||
|
use iced::advanced::{mouse, Clipboard, Shell, Widget};
|
||||||
|
use iced::{Element, Event, Length, Point, Rectangle, Size, Vector};
|
||||||
|
|
||||||
|
/// 把 `content` 按 `content_height` 排版、按 `visible_height` 裁切显示(内容**上端对齐**)。
|
||||||
|
///
|
||||||
|
/// `offset_y` 是内容在裁切框里的纵向位移(正 = 向下):入场从下方浮上来时给正值,
|
||||||
|
/// 退场上卷时给负值——两端都是 `0`(末帧与静息逐像素相同)。
|
||||||
|
pub fn revealed<'a, Message, Theme, Renderer>(
|
||||||
|
content: impl Into<Element<'a, Message, Theme, Renderer>>,
|
||||||
|
visible_height: f32,
|
||||||
|
content_height: f32,
|
||||||
|
offset_y: f32,
|
||||||
|
veil: Option<iced::Color>,
|
||||||
|
) -> Element<'a, Message, Theme, Renderer>
|
||||||
|
where
|
||||||
|
Message: 'a,
|
||||||
|
Theme: 'a,
|
||||||
|
Renderer: iced::advanced::Renderer + 'a,
|
||||||
|
{
|
||||||
|
Element::new(Revealed {
|
||||||
|
content: content.into(),
|
||||||
|
visible_height,
|
||||||
|
content_height,
|
||||||
|
offset_y,
|
||||||
|
veil,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Revealed<'a, Message, Theme, Renderer> {
|
||||||
|
content: Element<'a, Message, Theme, Renderer>,
|
||||||
|
/// 这一帧占的格子高(= 裁切框高)。
|
||||||
|
visible_height: f32,
|
||||||
|
/// 内容排版高(整段过渡里不变)。
|
||||||
|
content_height: f32,
|
||||||
|
/// 内容在裁切框里的纵向位移。
|
||||||
|
offset_y: f32,
|
||||||
|
/// 收尾遮罩(与底色同色;`None` = 静息,不画)。见模块文档「淡出」。
|
||||||
|
veil: Option<iced::Color>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
|
||||||
|
for Revealed<'_, Message, Theme, Renderer>
|
||||||
|
where
|
||||||
|
Renderer: iced::advanced::Renderer,
|
||||||
|
{
|
||||||
|
fn tag(&self) -> tree::Tag {
|
||||||
|
self.content.as_widget().tag()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state(&self) -> tree::State {
|
||||||
|
self.content.as_widget().state()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn children(&self) -> Vec<Tree> {
|
||||||
|
self.content.as_widget().children()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diff(&self, tree: &mut Tree) {
|
||||||
|
self.content.as_widget().diff(tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn size(&self) -> Size<Length> {
|
||||||
|
self.content.as_widget().size()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn size_hint(&self) -> Size<Length> {
|
||||||
|
self.content.as_widget().size_hint()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layout(
|
||||||
|
&mut self,
|
||||||
|
tree: &mut Tree,
|
||||||
|
renderer: &Renderer,
|
||||||
|
limits: &layout::Limits,
|
||||||
|
) -> layout::Node {
|
||||||
|
// 排版高**只**认 `content_height`:父级给的 max 高不参与(那正是「半行高重排」的来源)。
|
||||||
|
let child_limits = layout::Limits::new(
|
||||||
|
Size::ZERO,
|
||||||
|
Size::new(limits.max().width, self.content_height),
|
||||||
|
);
|
||||||
|
let node = self
|
||||||
|
.content
|
||||||
|
.as_widget_mut()
|
||||||
|
.layout(tree, renderer, &child_limits);
|
||||||
|
let width = if limits.max().width.is_finite() {
|
||||||
|
limits.max().width
|
||||||
|
} else {
|
||||||
|
node.size().width
|
||||||
|
};
|
||||||
|
layout::Node::with_children(
|
||||||
|
Size::new(width, self.visible_height.max(0.0)),
|
||||||
|
vec![node.move_to(Point::new(0.0, self.offset_y))],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(
|
||||||
|
&self,
|
||||||
|
tree: &Tree,
|
||||||
|
renderer: &mut Renderer,
|
||||||
|
theme: &Theme,
|
||||||
|
style: &renderer::Style,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
cursor: mouse::Cursor,
|
||||||
|
viewport: &Rectangle,
|
||||||
|
) {
|
||||||
|
let Some(child) = layout.children().next() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let bounds = layout.bounds();
|
||||||
|
let content = self.content.as_widget();
|
||||||
|
// `with_layer` 在两个后端都裁到 `bounds`(见模块文档)。
|
||||||
|
renderer.with_layer(bounds, |renderer| {
|
||||||
|
content.draw(tree, renderer, theme, style, child, cursor, viewport);
|
||||||
|
});
|
||||||
|
if let Some(veil) = self.veil.filter(|veil| veil.a > 0.0) {
|
||||||
|
// **必须自成一层**:tiny-skia 的绘制按「四边形 → 三角 → 图 → 文字」分批(`lib.rs` 的
|
||||||
|
// `present`),同一层里文字永远排在四边形之后 ⇒ 直接把遮罩塞进当前层的话,
|
||||||
|
// 它只盖得住底色,一个字都盖不住(M305 实测:红色 α=0.3 的遮罩,文字像素一像素没变)。
|
||||||
|
// 换成子层之后,子层的批排在父层文字**之后** ⇒ 真的盖住行内容。
|
||||||
|
renderer.with_layer(bounds, |renderer| {
|
||||||
|
renderer.fill_quad(
|
||||||
|
renderer::Quad {
|
||||||
|
bounds,
|
||||||
|
..renderer::Quad::default()
|
||||||
|
},
|
||||||
|
iced::Background::Color(veil),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn operate(
|
||||||
|
&mut self,
|
||||||
|
tree: &mut Tree,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
renderer: &Renderer,
|
||||||
|
operation: &mut dyn iced::advanced::widget::Operation,
|
||||||
|
) {
|
||||||
|
let Some(child) = layout.children().next() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.content
|
||||||
|
.as_widget_mut()
|
||||||
|
.operate(tree, child, renderer, operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(
|
||||||
|
&mut self,
|
||||||
|
tree: &mut Tree,
|
||||||
|
event: &Event,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
cursor: mouse::Cursor,
|
||||||
|
renderer: &Renderer,
|
||||||
|
clipboard: &mut dyn Clipboard,
|
||||||
|
shell: &mut Shell<'_, Message>,
|
||||||
|
viewport: &Rectangle,
|
||||||
|
) {
|
||||||
|
let Some(child) = layout.children().next() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// 收起到底(一格都不占)时不再收事件:退场行不该抢到一个已经点不到的按钮。
|
||||||
|
if self.visible_height < f32::EPSILON {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.content.as_widget_mut().update(
|
||||||
|
tree, event, child, cursor, renderer, clipboard, shell, viewport,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mouse_interaction(
|
||||||
|
&self,
|
||||||
|
tree: &Tree,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
cursor: mouse::Cursor,
|
||||||
|
viewport: &Rectangle,
|
||||||
|
renderer: &Renderer,
|
||||||
|
) -> mouse::Interaction {
|
||||||
|
let Some(child) = layout.children().next() else {
|
||||||
|
return mouse::Interaction::None;
|
||||||
|
};
|
||||||
|
if self.visible_height < f32::EPSILON {
|
||||||
|
return mouse::Interaction::None;
|
||||||
|
}
|
||||||
|
self.content
|
||||||
|
.as_widget()
|
||||||
|
.mouse_interaction(tree, child, cursor, viewport, renderer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlay<'a>(
|
||||||
|
&'a mut self,
|
||||||
|
tree: &'a mut Tree,
|
||||||
|
layout: Layout<'a>,
|
||||||
|
renderer: &Renderer,
|
||||||
|
viewport: &Rectangle,
|
||||||
|
translation: Vector,
|
||||||
|
) -> Option<iced::overlay::Element<'a, Message, Theme, Renderer>> {
|
||||||
|
let child = layout.children().next()?;
|
||||||
|
self.content
|
||||||
|
.as_widget_mut()
|
||||||
|
.overlay(tree, child, renderer, viewport, translation)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
//! 列表行增删的**公开采样口**(M305):页面只拿得到「这一帧这个 id 画成什么样」。
|
||||||
|
//!
|
||||||
|
//! ## 谁起跑 / 谁采样
|
||||||
|
//!
|
||||||
|
//! | 侧 | 函数 | 干什么 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | update | [`observe`] | 报一份**真值清单**(模型此刻的行 id,按显示顺序)。差分与起跑在泵里 |
|
||||||
|
//! | view | [`drawn`] | 这一帧该画哪些 id(退场行还钉在原来的位置) |
|
||||||
|
//! | view | [`frame`] | 这一帧某个 id 的可见度 + 纵向偏移 |
|
||||||
|
//!
|
||||||
|
//! **起跑必须在 update 侧**:iced 先算订阅表、后建视图(`app/subscription.rs` 的模块文档有源码
|
||||||
|
//! 行号),在 `view` 里插轨道当拍订阅不上——那正是 M192 r1 复审 P1-1 的原始缺陷。
|
||||||
|
//!
|
||||||
|
//! ## 退场行为什么要「留一份数据」
|
||||||
|
//!
|
||||||
|
//! [`drawn`] 会吐出**已经不在模型里**的 id(退场腿还在跑)。它们的行数据得由调用方留着
|
||||||
|
//! (`pages/download/model.rs` 的 `leaving` 池),否则那一路只能画一个空壳。
|
||||||
|
//! 这笔账是「删除项淡出后才真正移除」的代价:退场行必须还能被画出来,否则不叫淡出,叫消失。
|
||||||
|
//!
|
||||||
|
//! ## `MotionScale::NONE`
|
||||||
|
//!
|
||||||
|
//! [`observe`] 不插轨道、`order` 直接等于真值 ⇒ [`drawn`] 就是模型本身、[`frame`] 恒为
|
||||||
|
//! [`RowFrame::REST`](可见度**严格** 1、偏移**严格** 0)⇒ `needs_frames()` 不会因为列表变化
|
||||||
|
//! 而为真(一帧都不订阅)。
|
||||||
|
|
||||||
|
use super::pump::RowFrame;
|
||||||
|
use super::PUMP;
|
||||||
|
|
||||||
|
/// **update 侧**:报一份真值清单(模型此刻的行 id,按显示顺序)。
|
||||||
|
///
|
||||||
|
/// 幂等:同一份清单再报一次是空操作。返回 `true` = 真的起跑了轨道(帧基准由本模块重置)。
|
||||||
|
pub fn observe(ids: &[&str]) -> bool {
|
||||||
|
let started = PUMP.with(|pump| pump.borrow_mut().observe_rows(ids));
|
||||||
|
if started {
|
||||||
|
super::reset_frame_clock();
|
||||||
|
}
|
||||||
|
started
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **view 侧**:这一帧该画哪些 id(退场行还钉在原来的位置)。只采样、无副作用。
|
||||||
|
pub fn drawn() -> Vec<String> {
|
||||||
|
PUMP.with(|pump| {
|
||||||
|
pump.borrow()
|
||||||
|
.rows_drawn()
|
||||||
|
.into_iter()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **view 侧**:这一帧某个 id 的可见度 + 纵向偏移。只采样、无副作用。
|
||||||
|
pub fn frame(id: &str) -> RowFrame {
|
||||||
|
PUMP.with(|pump| pump.borrow().row_frame(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 行轨道在不在跑(日志与断言用;`needs_frames` 走的是泵的 `is_animating`)。
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn running() -> bool {
|
||||||
|
PUMP.with(|pump| pump.borrow().rows_animating())
|
||||||
|
}
|
||||||
@@ -10,7 +10,9 @@
|
|||||||
|
|
||||||
mod modal;
|
mod modal;
|
||||||
mod nav;
|
mod nav;
|
||||||
|
mod progress;
|
||||||
mod rail;
|
mod rail;
|
||||||
|
mod rows;
|
||||||
mod timeline;
|
mod timeline;
|
||||||
|
|
||||||
use super::pump::Pump;
|
use super::pump::Pump;
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
//! **进度插值**的守卫(M305):追值、单调、到点、收束一次、`NONE` 严格等于目标、不卡中间值。
|
||||||
|
//!
|
||||||
|
//! ## 变异账(T41:只改一处 ⇒ 期望红在哪条用例哪句断言)
|
||||||
|
//!
|
||||||
|
//! | 变异(只改这一处) | 期望红在哪 |
|
||||||
|
//! |---|---|
|
||||||
|
//! | `pump/progress.rs` 的 `Progress::value` 里把插值那一行换成 `Some(track.to)`(**改回硬跳**) | `the_value_chases_the_target_instead_of_jumping` 的「没有一帧落在两端之间」(实测见报告) |
|
||||||
|
//! | `pump/progress.rs` 的 `expire` 里删掉「`track.to >= 1.0` 才排收束」 | `the_settle_fires_once_and_is_zero_at_both_ends`(中途跳变也会亮一下) |
|
||||||
|
//! | `pump/progress.rs` 的 `settle_amount` 去掉 `if t >= 1.0 { return 0.0 }` | 同一条用例的「末帧必须严格为 0」(脉冲会停在中途) |
|
||||||
|
//! | `pump/progress.rs` 的 `observe_progress` 里删掉 `is_off()` 那一支 | `no_scale_renders_the_target_itself`(`samples()` 非 0) |
|
||||||
|
//! | `pump/progress.rs` 的 `progress_value` 里删掉「目标不符就给目标」 | `a_model_that_skips_the_observe_is_not_stuck_midway` |
|
||||||
|
|
||||||
|
use super::super::pump::Pump;
|
||||||
|
use super::super::transition::progress_spec;
|
||||||
|
use super::super::*;
|
||||||
|
use unlucky_design::motion::MotionScale;
|
||||||
|
|
||||||
|
fn standard() -> Pump {
|
||||||
|
let mut pump = Pump::new();
|
||||||
|
pump.configure(MotionScale::STANDARD, None);
|
||||||
|
pump
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 推进到没有轨道为止(每帧 16ms,上限 20s)。
|
||||||
|
fn settle(pump: &mut Pump) {
|
||||||
|
for _ in 0..1250 {
|
||||||
|
if !pump.is_animating() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pump.advance(FRAME_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 真实进度一跳,画出来的值**沿一条腿追上去**(不是整数值硬跳),且单调不回弹。
|
||||||
|
///
|
||||||
|
/// 抓得住:把插值改回 `to`(第一帧就到位);或者插值方向做反(值往回走)。
|
||||||
|
/// 抓不住:观感(出图在 `DevDocs/artifacts/m305/`)。
|
||||||
|
#[test]
|
||||||
|
fn the_value_chases_the_target_instead_of_jumping() {
|
||||||
|
let mut pump = standard();
|
||||||
|
// 第一次报 = 认条,不追值(没有「上一根的尾巴」可追)
|
||||||
|
assert!(!pump.observe_progress("install-1", 0.20));
|
||||||
|
assert_eq!(pump.progress_value("install-1", 0.20), 0.20);
|
||||||
|
|
||||||
|
// 后端跳了 60 个百分点:画出来要一路追
|
||||||
|
assert!(pump.observe_progress("install-1", 0.80));
|
||||||
|
let mut seen = vec![pump.progress_value("install-1", 0.80)];
|
||||||
|
assert_eq!(seen[0], 0.20, "起跑那一帧必须还是旧值(不许硬跳)");
|
||||||
|
for _ in 0..1250 {
|
||||||
|
if !pump.is_animating() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pump.advance(FRAME_MS);
|
||||||
|
seen.push(pump.progress_value("install-1", 0.80));
|
||||||
|
}
|
||||||
|
assert!(seen.len() > 4, "追值要跨多帧:{seen:?}");
|
||||||
|
assert!(
|
||||||
|
seen.windows(2).all(|pair| pair[1] >= pair[0] - 1.0e-6),
|
||||||
|
"进度不许倒退(那读起来像卡了):{seen:?}"
|
||||||
|
);
|
||||||
|
// 中间帧必须**真的落在两端之间**:这条是「改回硬跳」那一路的落点(硬跳时 seen 只有 0.20 与 0.80)
|
||||||
|
assert!(
|
||||||
|
seen.iter()
|
||||||
|
.any(|value| *value > 0.20 + 1.0e-3 && *value < 0.80 - 1.0e-3),
|
||||||
|
"没有一帧落在两端之间 ⇒ 插值退回了硬跳:{seen:?}"
|
||||||
|
);
|
||||||
|
let end = *seen.last().unwrap();
|
||||||
|
assert!((end - 0.80).abs() < 1.0e-6, "收尾必须严格到目标:{end}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 到达 100% 有**一次**强调收束:端点严格 0、中间非 0、**不循环**。
|
||||||
|
#[test]
|
||||||
|
fn the_settle_fires_once_and_is_zero_at_both_ends() {
|
||||||
|
let mut pump = standard();
|
||||||
|
pump.observe_progress("install-1", 0.90);
|
||||||
|
settle(&mut pump);
|
||||||
|
assert!(!pump.is_animating(), "前提:在追的那一腿已经走完");
|
||||||
|
assert_eq!(
|
||||||
|
pump.progress_settle("install-1", 0.90),
|
||||||
|
0.0,
|
||||||
|
"没到 100% 就没有收束"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(pump.observe_progress("install-1", 1.0));
|
||||||
|
// 追值那一腿走完的那一帧才开始排收束
|
||||||
|
for _ in 0..1250 {
|
||||||
|
if pump.progress_settle("install-1", 1.0) > 0.0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pump.advance(FRAME_MS);
|
||||||
|
}
|
||||||
|
let peak = pump.progress_settle("install-1", 1.0);
|
||||||
|
assert!(peak > 0.0, "到达 100% 必须有一次收束");
|
||||||
|
// 收束期间值恒为目标(不许被脉冲带偏)
|
||||||
|
assert!(
|
||||||
|
(pump.progress_value("install-1", 1.0) - 1.0).abs() < 1.0e-6,
|
||||||
|
"收束只改强调,不改数值"
|
||||||
|
);
|
||||||
|
|
||||||
|
let spec = progress_spec();
|
||||||
|
let mut samples = vec![peak];
|
||||||
|
for _ in 0..1250 {
|
||||||
|
if !pump.is_animating() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pump.advance(FRAME_MS);
|
||||||
|
samples.push(pump.progress_settle("install-1", 1.0));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
*samples.last().unwrap() == 0.0,
|
||||||
|
"收束必须**严格**归零(末帧与静息逐位相同):{samples:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
samples.iter().all(|sample| *sample <= 1.0),
|
||||||
|
"脉冲强度不许越界"
|
||||||
|
);
|
||||||
|
// 不循环:收尾之后一直为 0
|
||||||
|
for _ in 0..10 {
|
||||||
|
pump.advance(FRAME_MS);
|
||||||
|
assert_eq!(pump.progress_settle("install-1", 1.0), 0.0, "不许循环");
|
||||||
|
}
|
||||||
|
let _ = spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **`MotionScale::NONE` 严格等于目标**、零采样(任务里那条「值仍是最终值」的断言版)。
|
||||||
|
#[test]
|
||||||
|
fn no_scale_renders_the_target_itself() {
|
||||||
|
let mut pump = Pump::new();
|
||||||
|
pump.configure(MotionScale::NONE, None);
|
||||||
|
pump.observe_progress("install-1", 0.10);
|
||||||
|
assert!(!pump.observe_progress("install-1", 0.95));
|
||||||
|
assert_eq!(pump.progress_value("install-1", 0.95), 0.95);
|
||||||
|
assert!(!pump.is_animating());
|
||||||
|
assert_eq!(pump.samples(), 0);
|
||||||
|
|
||||||
|
assert!(!pump.observe_progress("install-1", 1.0));
|
||||||
|
assert_eq!(
|
||||||
|
pump.progress_settle("install-1", 1.0),
|
||||||
|
0.0,
|
||||||
|
"关掉动效不许有收束脉冲"
|
||||||
|
);
|
||||||
|
assert_eq!(pump.samples(), 0, "NONE 档一帧都不许采样");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模型跳过了 `observe`(截图 harness / 单测直接构造模型):**宁可无动效,也不许卡在中间值**。
|
||||||
|
#[test]
|
||||||
|
fn a_model_that_skips_the_observe_is_not_stuck_midway() {
|
||||||
|
let mut pump = standard();
|
||||||
|
pump.observe_progress("install-1", 0.20);
|
||||||
|
assert!(pump.observe_progress("install-1", 0.40));
|
||||||
|
pump.advance(FRAME_MS);
|
||||||
|
let mid = pump.progress_value("install-1", 0.40);
|
||||||
|
assert!(mid > 0.20 && mid < 0.40, "前提:正在追({mid})");
|
||||||
|
|
||||||
|
// 有人把目标直接改成 0.90 却没走 observe:必须直接给 0.90
|
||||||
|
assert_eq!(
|
||||||
|
pump.progress_value("install-1", 0.90),
|
||||||
|
0.90,
|
||||||
|
"目标不符时必须给目标(不许画一个谁也说不清的中间值)"
|
||||||
|
);
|
||||||
|
// 换了一根条(另一个安装)同理
|
||||||
|
assert_eq!(pump.progress_value("install-2", 0.50), 0.50);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 换一根条**不追值**:新安装不该从上一根的位置爬过来。
|
||||||
|
#[test]
|
||||||
|
fn a_new_bar_does_not_chase_from_the_previous_one() {
|
||||||
|
let mut pump = standard();
|
||||||
|
pump.observe_progress("install-1", 0.10);
|
||||||
|
assert!(pump.observe_progress("install-1", 0.90));
|
||||||
|
pump.advance(FRAME_MS);
|
||||||
|
|
||||||
|
assert!(!pump.observe_progress("install-2", 0.30));
|
||||||
|
assert_eq!(
|
||||||
|
pump.progress_value("install-2", 0.30),
|
||||||
|
0.30,
|
||||||
|
"换条必须直接落目标(否则会从上一根的 0.2 爬到 0.3)"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
//! **列表增删轨道**的守卫(M305):差分、退场钉位、同一条时钟、`NONE` 退化、空闲不烧帧。
|
||||||
|
//!
|
||||||
|
//! 判据按 T37/T38 写:从**驱动源**(`Pump::observe_rows` + `Pump::advance`)连拍,不等真实时间;
|
||||||
|
//! 每条都写清「抓得住什么 / 抓不住什么」。
|
||||||
|
//!
|
||||||
|
//! ## 变异账(T41:只改一处 ⇒ 期望红在哪条用例哪句断言)
|
||||||
|
//!
|
||||||
|
//! | 变异(只改这一处) | 期望红在哪 |
|
||||||
|
//! |---|---|
|
||||||
|
//! | `pump/rows.rs` 的 `Rows::drawn` 里删掉「插入新 id」那一段(退场行照旧钉位) | `an_exit_row_stays_in_place_until_its_leg_finishes` 的「还没走完时 b 必须还在原位」 |
|
||||||
|
//! | `pump/rows.rs` 的 `Rows::expire` 里删掉 `self.tracks.retain(..)`(走完不删轨道) | `frames_after_the_leg_finishes_are_not_requested`(`advance` 一直为真、`is_animating` 恒真) |
|
||||||
|
//! | `pump/rows.rs` 的 `frame` 里把 `track.sample(..)` 换成 `track.to`(**改回硬跳**) | `an_enter_row_climbs_and_an_exit_row_descends_on_the_same_clock` 的中间帧断言(可见度要么 0 要么 1) |
|
||||||
|
//! | `pump/rows.rs` 的 `observe_rows` 里删掉 `scale.is_off()` 那一支 | `no_scale_lands_every_row_on_the_final_state`(`samples()` 非 0、`needs_frames()` 为真) |
|
||||||
|
//! | `pump/rows.rs` 的 `retarget` 里 `slots` 用 `index` 而不是 `min(index, max_stagger)` | `the_stagger_is_capped_so_a_long_list_does_not_wait_forever` |
|
||||||
|
|
||||||
|
use super::super::pump::Pump;
|
||||||
|
use super::super::pump::RowFrame;
|
||||||
|
use super::super::transition::row_spec;
|
||||||
|
use super::super::*;
|
||||||
|
use unlucky_design::motion::MotionScale;
|
||||||
|
|
||||||
|
fn standard() -> Pump {
|
||||||
|
let mut pump = Pump::new();
|
||||||
|
pump.configure(MotionScale::STANDARD, None);
|
||||||
|
pump
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 只看某个 id 的可见度。
|
||||||
|
fn visibility(pump: &Pump, id: &str) -> f32 {
|
||||||
|
pump.row_frame(id).visibility
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一路采样到轨道结束(每帧 16ms,上限 20s),返回采到的可见度序列。
|
||||||
|
fn run(pump: &mut Pump, id: &str) -> Vec<f32> {
|
||||||
|
let mut out = vec![visibility(pump, id)];
|
||||||
|
for _ in 0..1250 {
|
||||||
|
if !pump.is_animating() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pump.advance(FRAME_MS);
|
||||||
|
out.push(visibility(pump, id));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **进 / 出两腿读同一个时间源**,且中间帧真的是中间值(不是硬跳)。
|
||||||
|
///
|
||||||
|
/// 抓得住:①把 `frame` 改成硬跳(`track.to`)——中间帧会全是 0/1;②进/出各用一条时钟
|
||||||
|
/// (例如退场读 `now_ms`、进场读 `elapsed_ms`)——两边算出来的相位对不上。
|
||||||
|
/// 抓不住:观感(出图在 `DevDocs/artifacts/m305/`)。
|
||||||
|
#[test]
|
||||||
|
fn an_enter_row_climbs_and_an_exit_row_descends_on_the_same_clock() {
|
||||||
|
let mut pump = standard();
|
||||||
|
// 先对表。"x" 与 "d" 都在**第 0 位**:错位档为 0 ⇒ 两条腿同一拍起跑(这才是「同一条时钟」)。
|
||||||
|
assert!(!pump.observe_rows(&["x", "b"]));
|
||||||
|
// 一次变化里同时有进(d)与出(x)
|
||||||
|
assert!(pump.observe_rows(&["d", "b"]));
|
||||||
|
|
||||||
|
let spec = row_spec();
|
||||||
|
// 第一帧(起点):进场 0、退场 1
|
||||||
|
assert_eq!(visibility(&pump, "d"), 0.0, "进场起点必须是 0");
|
||||||
|
assert_eq!(visibility(&pump, "x"), 1.0, "退场起点必须是 1");
|
||||||
|
assert_eq!(visibility(&pump, "b"), 1.0, "没变的行必须纹丝不动");
|
||||||
|
|
||||||
|
pump.advance(FRAME_MS);
|
||||||
|
let now = pump.now_ms();
|
||||||
|
let enter_ms = pump.row_leg_ms(true);
|
||||||
|
let exit_ms = pump.row_leg_ms(false);
|
||||||
|
let enter_expected = spec.enter.easing.sample((now / enter_ms) as f32);
|
||||||
|
let exit_expected = 1.0 - spec.exit.easing.sample((now / exit_ms) as f32);
|
||||||
|
assert!(
|
||||||
|
(visibility(&pump, "d") - enter_expected).abs() < 1.0e-5,
|
||||||
|
"进场的中间帧应当是同一条时间轴上的采样:实得 {} 期望 {enter_expected}",
|
||||||
|
visibility(&pump, "d")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(visibility(&pump, "x") - exit_expected).abs() < 1.0e-5,
|
||||||
|
"退场的中间帧应当是同一条时间轴上的采样:实得 {} 期望 {exit_expected}",
|
||||||
|
visibility(&pump, "x")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
now > 0.0 && now < enter_ms.max(exit_ms),
|
||||||
|
"这一拍必须落在「两边都还没走完」的窗口里(now={now})"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 覆盖「状态已切换但没有任何事件」的时段:一条消息都不再来,只推进时间轴。
|
||||||
|
let enter_frames = run(&mut pump, "d");
|
||||||
|
assert!(enter_frames.len() > 4, "进场要跨多帧:{enter_frames:?}");
|
||||||
|
assert!(
|
||||||
|
enter_frames.windows(2).all(|pair| pair[1] >= pair[0]),
|
||||||
|
"进场必须单调往上:{enter_frames:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(*enter_frames.last().unwrap(), 1.0, "收尾必须严格到 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 退场行**走完才移除**:这一段里它还在画的顺序里,且下面那些行不许提前上移。
|
||||||
|
///
|
||||||
|
/// 抓得住:把退场行在 observe 当拍就从 `drawn` 里摘掉(那就是整表瞬间重排闪跳)。
|
||||||
|
/// 抓不住:收尾那一帧的像素(高度收到 0 之后才删,出图证明)。
|
||||||
|
#[test]
|
||||||
|
fn an_exit_row_stays_in_place_until_its_leg_finishes() {
|
||||||
|
let mut pump = standard();
|
||||||
|
// 退场行放在第 0 位(错位档 0):这条用例只看「钉位」与「走完才删」,不看错位。
|
||||||
|
pump.observe_rows(&["b", "a", "c"]);
|
||||||
|
pump.observe_rows(&["a", "c"]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
pump.rows_drawn(),
|
||||||
|
vec!["b", "a", "c"],
|
||||||
|
"退场腿还在跑时 b 必须留在原位(否则 a/c 会当场跳上来)"
|
||||||
|
);
|
||||||
|
// 推进半程:还在
|
||||||
|
for _ in 0..3 {
|
||||||
|
pump.advance(FRAME_MS);
|
||||||
|
}
|
||||||
|
assert!(visibility(&pump, "b") < 1.0, "前提:退场真的在走");
|
||||||
|
assert_eq!(pump.rows_drawn(), vec!["b", "a", "c"], "半程时顺序不许变");
|
||||||
|
|
||||||
|
// 走完之后:b 才消失,且顺序归位
|
||||||
|
for _ in 0..1250 {
|
||||||
|
if !pump.is_animating() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pump.advance(FRAME_MS);
|
||||||
|
}
|
||||||
|
assert_eq!(pump.rows_drawn(), vec!["a", "c"], "退场走完才移除");
|
||||||
|
assert!(
|
||||||
|
!pump.is_animating(),
|
||||||
|
"轨道到期必须当场删(否则空闲还在要帧)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 新行插在**它该在的位置**,不是在末尾追加。
|
||||||
|
#[test]
|
||||||
|
fn an_enter_row_lands_between_its_neighbours() {
|
||||||
|
let mut pump = standard();
|
||||||
|
pump.observe_rows(&["a", "c"]);
|
||||||
|
pump.observe_rows(&["a", "b", "c"]);
|
||||||
|
assert_eq!(pump.rows_drawn(), vec!["a", "b", "c"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **结束后一帧都不再要**(T38 ④ 的算术那一半):`advance` 返回假、`samples` 不再增长。
|
||||||
|
///
|
||||||
|
/// 抓得住:到期不删轨道(`advance` 会永远为真 ⇒ 空闲烧帧)。
|
||||||
|
/// 抓不住:`App::view_dirty`(那是 app 侧的门控,不在这条用例的能力范围里)。
|
||||||
|
#[test]
|
||||||
|
fn frames_after_the_leg_finishes_are_not_requested() {
|
||||||
|
let mut pump = standard();
|
||||||
|
pump.observe_rows(&["a"]);
|
||||||
|
pump.observe_rows(&["a", "b"]);
|
||||||
|
for _ in 0..1250 {
|
||||||
|
if !pump.is_animating() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pump.advance(FRAME_MS);
|
||||||
|
}
|
||||||
|
assert!(!pump.is_animating(), "前提:已经收尾");
|
||||||
|
let samples = pump.samples();
|
||||||
|
for _ in 0..10 {
|
||||||
|
assert!(!pump.advance(FRAME_MS), "收尾后 `advance` 不许再报推进");
|
||||||
|
}
|
||||||
|
assert_eq!(pump.samples(), samples, "收尾后一帧都不许再采样");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **`MotionScale::NONE` 严格落到最终状态**:可见度 1.0、偏移 0.0、零采样、零订阅。
|
||||||
|
///
|
||||||
|
/// 这是任务里那条硬要求的断言版:「值仍是最终值,不得停在中途」。
|
||||||
|
#[test]
|
||||||
|
fn no_scale_lands_every_row_on_the_final_state() {
|
||||||
|
let mut pump = Pump::new();
|
||||||
|
pump.configure(MotionScale::NONE, None);
|
||||||
|
pump.observe_rows(&["a", "b"]);
|
||||||
|
assert!(!pump.observe_rows(&["a", "b", "c"]), "关掉动效不许起轨道");
|
||||||
|
assert!(!pump.is_animating());
|
||||||
|
assert_eq!(pump.samples(), 0);
|
||||||
|
|
||||||
|
for id in ["a", "b", "c"] {
|
||||||
|
let frame = pump.row_frame(id);
|
||||||
|
assert_eq!(
|
||||||
|
frame,
|
||||||
|
RowFrame::REST,
|
||||||
|
"{id} 在 NONE 档必须是静息(可见度 1、偏移 0),实得 {frame:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(pump.rows_drawn(), vec!["a", "b", "c"], "NONE 不留退场幽灵");
|
||||||
|
|
||||||
|
// 反向(删一行)同样是瞬时的
|
||||||
|
pump.observe_rows(&["a", "c"]);
|
||||||
|
assert_eq!(pump.rows_drawn(), vec!["a", "c"]);
|
||||||
|
assert_eq!(pump.samples(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 错位有上限:长列表不许「越往后越等」。
|
||||||
|
///
|
||||||
|
/// 抓得住:`retarget` 里直接用 `index` 当错位档(50 行时最后一行要等 2.5s)。
|
||||||
|
#[test]
|
||||||
|
fn the_stagger_is_capped_so_a_long_list_does_not_wait_forever() {
|
||||||
|
let mut pump = standard();
|
||||||
|
let before: Vec<String> = Vec::new();
|
||||||
|
let ids: Vec<String> = (0..40).map(|index| format!("v{index}")).collect();
|
||||||
|
let _ = before;
|
||||||
|
let refs: Vec<&str> = ids.iter().map(String::as_str).collect();
|
||||||
|
pump.observe_rows(&[]);
|
||||||
|
pump.observe_rows(&refs);
|
||||||
|
|
||||||
|
let spec = row_spec();
|
||||||
|
let stagger = f64::from(MotionScale::STANDARD.ms(spec.stagger));
|
||||||
|
let cap = f64::from(spec.max_stagger) * stagger + f64::from(spec.enter.duration.ms());
|
||||||
|
let mut frames = 0;
|
||||||
|
for _ in 0..1250 {
|
||||||
|
if !pump.is_animating() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pump.advance(FRAME_MS);
|
||||||
|
frames += 1;
|
||||||
|
}
|
||||||
|
let total_ms = f64::from(frames) * FRAME_MS;
|
||||||
|
assert!(
|
||||||
|
total_ms <= cap + FRAME_MS * 2.0,
|
||||||
|
"40 行的总时长 {total_ms}ms 超过「错位封顶 + 一条进场腿」的上界 {cap}ms"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同一份清单再报一次是**空操作**(视图重建 / 截图 harness 每帧重建模型都靠这条)。
|
||||||
|
#[test]
|
||||||
|
fn observing_the_same_list_twice_is_a_no_op() {
|
||||||
|
let mut pump = standard();
|
||||||
|
pump.observe_rows(&["a", "b"]);
|
||||||
|
assert!(pump.observe_rows(&["a", "b", "c"]));
|
||||||
|
pump.advance(FRAME_MS);
|
||||||
|
let mid = visibility(&pump, "c");
|
||||||
|
assert!(!pump.observe_rows(&["a", "b", "c"]), "同一份清单不许重起");
|
||||||
|
assert_eq!(visibility(&pump, "c"), mid, "进度不许被重置");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 退场中途又回列表:接着当前进度往回走,**不许跳回 0**(错位入也不许闪一下)。
|
||||||
|
#[test]
|
||||||
|
fn a_row_that_returns_mid_exit_does_not_jump() {
|
||||||
|
let mut pump = standard();
|
||||||
|
// b 在第 0 位(错位档 0):三帧之后必定走到中途。
|
||||||
|
pump.observe_rows(&["b", "a"]);
|
||||||
|
pump.observe_rows(&["a"]);
|
||||||
|
for _ in 0..3 {
|
||||||
|
pump.advance(FRAME_MS);
|
||||||
|
}
|
||||||
|
let low = visibility(&pump, "b");
|
||||||
|
assert!(low > 0.0 && low < 1.0, "前提:退场走到一半({low})");
|
||||||
|
|
||||||
|
pump.observe_rows(&["b", "a"]);
|
||||||
|
assert_eq!(visibility(&pump, "b"), low, "重新回来必须接着当前进度");
|
||||||
|
pump.advance(FRAME_MS);
|
||||||
|
let later = visibility(&pump, "b");
|
||||||
|
assert!(later > low, "回来之后必须往 1 走({low} → {later})");
|
||||||
|
}
|
||||||
@@ -195,6 +195,70 @@ pub const fn rail_spec() -> RailSpec {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **列表行增删**的规格(M305,唯一来源)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct RowSpec {
|
||||||
|
/// 入场一条腿(行从「收起到静息」)。
|
||||||
|
pub enter: Leg,
|
||||||
|
/// 退场一条腿(行从「静息到收起」)——**必须短于**进场,退场只是让位。
|
||||||
|
pub exit: Leg,
|
||||||
|
/// 相邻两行的起跑间隔(错位入)。
|
||||||
|
pub stagger: DurationToken,
|
||||||
|
/// 最多错位几档:列表很长时不许「越往后越等」。
|
||||||
|
pub max_stagger: u8,
|
||||||
|
/// 进出时的纵向滑动幅度(正 = 入场从下方浮上来、退场上卷)。
|
||||||
|
pub slide: Space,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列表行规格。
|
||||||
|
///
|
||||||
|
/// 三条口径:①`Short4`(200) 进 / `Short3`(150) 出,都在 M3E 短档内且**进略长于出**;
|
||||||
|
/// ②错位一档 `Short1`(50),最多 3 档(后续行 150ms 后一起走完,8 行的列表总共 350ms);
|
||||||
|
/// ③滑动幅度取 `Space::X2`(8)——落在 4px 栅格上,且**小于**页面切换的 12/16(列表是局部变化,
|
||||||
|
/// 幅度不能盖过整屏切换)。
|
||||||
|
pub const fn row_spec() -> RowSpec {
|
||||||
|
RowSpec {
|
||||||
|
enter: Leg {
|
||||||
|
duration: DurationToken::Short4,
|
||||||
|
easing: Easing::EmphasizedDecelerate,
|
||||||
|
},
|
||||||
|
exit: Leg {
|
||||||
|
duration: DurationToken::Short3,
|
||||||
|
easing: Easing::EmphasizedAccelerate,
|
||||||
|
},
|
||||||
|
stagger: DurationToken::Short1,
|
||||||
|
max_stagger: 3,
|
||||||
|
slide: Space::X2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **进度插值**的规格(M305,唯一来源)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct ProgressSpec {
|
||||||
|
/// 追值那一腿(真实进度一跳,画出来的值沿这条腿追上去)。
|
||||||
|
pub advance: Leg,
|
||||||
|
/// 到达后的**一次**强调收束(脉冲:0→1→0,不循环)。
|
||||||
|
pub arrive: Leg,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 进度插值规格。
|
||||||
|
///
|
||||||
|
/// 为什么追值用 `Standard` 而不是弹簧:进度是**单调读数**,回弹会让「99%」倒退回「98%」,
|
||||||
|
/// 用户读到的是「卡了」。收束用 `Emphasized` 的脉冲(见 `pump/progress.rs`):
|
||||||
|
/// 到 100% 时亮一下再落回静息色,一次就够(循环会变成「一直在闪」)。
|
||||||
|
pub const fn progress_spec() -> ProgressSpec {
|
||||||
|
ProgressSpec {
|
||||||
|
advance: Leg {
|
||||||
|
duration: DurationToken::Short4,
|
||||||
|
easing: Easing::Standard,
|
||||||
|
},
|
||||||
|
arrive: Leg {
|
||||||
|
duration: DurationToken::Short3,
|
||||||
|
easing: Easing::Emphasized,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 一帧页面采样(**只读**:`pages::render` 每帧取一次,不起轨道)。
|
/// 一帧页面采样(**只读**:`pages::render` 每帧取一次,不起轨道)。
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub struct PageFrame {
|
pub struct PageFrame {
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ use unlucky_presenter::state::AppState;
|
|||||||
|
|
||||||
use unlucky_i18n::Catalog;
|
use unlucky_i18n::Catalog;
|
||||||
|
|
||||||
|
mod motion;
|
||||||
|
|
||||||
|
use motion::list_motion;
|
||||||
|
|
||||||
use super::model::{Category, Download, InstallView, VersionRow};
|
use super::model::{Category, Download, InstallView, VersionRow};
|
||||||
use super::options::{InstallDraft, Loader};
|
use super::options::{InstallDraft, Loader};
|
||||||
|
|
||||||
@@ -113,6 +117,12 @@ pub fn demo(catalog: Catalog) -> Download {
|
|||||||
// 复审 P2-2 的取样点:**22 个主版本桶**(MOJANG 清单里 `Release` 就是这个量级)。
|
// 复审 P2-2 的取样点:**22 个主版本桶**(MOJANG 清单里 `Release` 就是这个量级)。
|
||||||
// chips 档一行放不下 ⇒ 主版本行必须能横向滚,否则后面的桶永远点不到。
|
// chips 档一行放不下 ⇒ 主版本行必须能横向滚,否则后面的桶永远点不到。
|
||||||
Some("many-buckets") => many(catalog),
|
Some("many-buckets") => many(catalog),
|
||||||
|
// M305 的两帧中间态(出图取证):**走产品那条真差分**,只是把「上一次观察」按 Once 摆一次。
|
||||||
|
// `list-enter` = 分类从「远古版」放开到「全部」(2 行 → 8 行,六条错位进去);
|
||||||
|
// `list-exit` = 反过来(八条里退掉六条,退场行还钉在原位淡出)。
|
||||||
|
// 相位由 `ULL_MOTION_AT` 钉死,所以同一张图每次都一样(D30)。
|
||||||
|
Some("list-enter") => list_motion(catalog, true),
|
||||||
|
Some("list-exit") => list_motion(catalog, false),
|
||||||
Some("installing-empty") => Download {
|
Some("installing-empty") => Download {
|
||||||
install: Some(install(InstallStage::Downloading, 0.42, catalog)),
|
install: Some(install(InstallStage::Downloading, 0.42, catalog)),
|
||||||
..Download::loading()
|
..Download::loading()
|
||||||
@@ -145,6 +155,8 @@ fn ready(catalog: Catalog) -> Download {
|
|||||||
query: String::new(),
|
query: String::new(),
|
||||||
selected_major: None,
|
selected_major: None,
|
||||||
type_rail_folded: false,
|
type_rail_folded: false,
|
||||||
|
// 演示模型没有「还在退场」的行:动效只在快照重建时由泵产生(M305)。
|
||||||
|
leaving: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
//! **列表增删动效的中间态**取样点(M305):`ULL_DL_DEMO=list-enter|list-exit`。
|
||||||
|
//!
|
||||||
|
//! 从 `demo.rs` 拆出来(那边正好 397 行、零余量):本文件只管「制造一次真实的增删」。
|
||||||
|
//!
|
||||||
|
//! ## 两件事都走产品路径
|
||||||
|
//!
|
||||||
|
//! 1. 模型由 [`Download::from_snapshot`] 建(分类过滤、退场池都在里面);
|
||||||
|
//! 2. 差分由泵真做(`observe_rows` 比对两次观察),**不是**硬塞一个 progress 值。
|
||||||
|
//!
|
||||||
|
//! ## 唯一的「演示特权」是顺序与定档
|
||||||
|
//!
|
||||||
|
//! 产品里两次观察隔着一条事件;这里在同一个 view 里连做两次(`Once` 只做一次,否则每帧重摆会把
|
||||||
|
//! 轨道重起)。相位由 `ULL_MOTION_AT` 钉死 ⇒ 同一张图每次都一样(D30)。
|
||||||
|
//!
|
||||||
|
//! 为什么定档(`motion::configure`)在这一层:截图走的是 `pages/harness.rs` 那条独立 app,
|
||||||
|
//! 它**不建 `App`**,也就不会经过 `app/update.rs` 的定档;而 harness 不在本 mission 的 scope 里。
|
||||||
|
//! 给了 `ULL_MOTION_AT` 才开动效,没给就保持静息——与产品截图通道「不钉相位就是静帧」同一条纪律。
|
||||||
|
|
||||||
|
use unlucky_design::motion::MotionScale;
|
||||||
|
use unlucky_i18n::Catalog;
|
||||||
|
use unlucky_presenter::dto::{AppEvent, CoreEvent};
|
||||||
|
use unlucky_presenter::state::AppState;
|
||||||
|
|
||||||
|
use crate::app::components::motion;
|
||||||
|
use crate::pages::download::model::{Category, Download};
|
||||||
|
use crate::pages::download::options::InstallDraft;
|
||||||
|
|
||||||
|
use super::demo_versions;
|
||||||
|
|
||||||
|
/// 演一次「分类从 A 换到 B」的列表增删,返回**换完之后**的模型(退场池已填好)。
|
||||||
|
pub(super) fn list_motion(catalog: Catalog, entering: bool) -> Download {
|
||||||
|
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||||
|
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.apply(AppEvent::Core(CoreEvent::VersionListChanged(
|
||||||
|
demo_versions(),
|
||||||
|
)));
|
||||||
|
let draft = InstallDraft::default();
|
||||||
|
ONCE.call_once(|| {
|
||||||
|
// 出图通道的定档:harness 不建 `App`,所以这一档只能由演示模型自己钉——`ULL_MOTION_AT`
|
||||||
|
// 给了相位才开动效,否则保持静息(D30:没钉相位的出图不许有动画)。
|
||||||
|
let pin = std::env::var("ULL_MOTION_AT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|raw| raw.trim().parse::<f64>().ok());
|
||||||
|
if pin.is_some() {
|
||||||
|
motion::configure(MotionScale::STANDARD, pin);
|
||||||
|
}
|
||||||
|
// 用「远古版 ⇄ 全部」而不是「正式版 ⇄ 全部」:远古版只有两行、落在列表**最前面**,
|
||||||
|
// 于是进去的 6 行错位档是 0/1/2/3/3/3(`list-enter` 图里能一眼看出「错位」);
|
||||||
|
// 反过来退的那 6 行同理。
|
||||||
|
let (before, after) = if entering {
|
||||||
|
(Category::Ancient, Category::All)
|
||||||
|
} else {
|
||||||
|
(Category::All, Category::Ancient)
|
||||||
|
};
|
||||||
|
// 两次真转换 = 两次真观察(第二次的差分就是这一次增删)。
|
||||||
|
let _ = Download::from_snapshot(&state, before, &draft, catalog);
|
||||||
|
let _ = Download::from_snapshot(&state, after, &draft, catalog);
|
||||||
|
});
|
||||||
|
|
||||||
|
let category = if entering {
|
||||||
|
Category::All
|
||||||
|
} else {
|
||||||
|
Category::Ancient
|
||||||
|
};
|
||||||
|
Download::from_snapshot(&state, category, &draft, catalog)
|
||||||
|
}
|
||||||
@@ -75,7 +75,8 @@ use unlucky_presenter::router::Route;
|
|||||||
use unlucky_i18n::{Catalog, Key};
|
use unlucky_i18n::{Catalog, Key};
|
||||||
|
|
||||||
use crate::app::components::buttons::{self, Button};
|
use crate::app::components::buttons::{self, Button};
|
||||||
use crate::app::components::feedback::{linear, Progress};
|
use crate::app::components::feedback::{linear_with, Progress};
|
||||||
|
use crate::app::components::motion;
|
||||||
use crate::theme::Ui;
|
use crate::theme::Ui;
|
||||||
|
|
||||||
use super::super::frame;
|
use super::super::frame;
|
||||||
@@ -209,7 +210,15 @@ fn installing<'a>(ui: &'a Ui, catalog: Catalog, install: &'a InstallView) -> Ele
|
|||||||
let mut body = column![head];
|
let mut body = column![head];
|
||||||
// 进度已知才画条:未知时画 indeterminate 会得到一个静止的假进度(见模块文档)。
|
// 进度已知才画条:未知时画 indeterminate 会得到一个静止的假进度(见模块文档)。
|
||||||
if install.known {
|
if install.known {
|
||||||
body = body.push(linear(ui, Progress::determinate(install.ratio)));
|
// M305:画的是**插值后**的值,不是后端那个整数跳变的读数;到 100% 那一下有一次收束。
|
||||||
|
// 起跑在 update 侧(`model.rs::from_snapshot` 的 `motion::progress::observe`),
|
||||||
|
// 这里只采样(页面的口径:`view` 不许起轨道)。
|
||||||
|
let sample = motion::progress::frame(install.version.as_str(), install.ratio);
|
||||||
|
body = body.push(linear_with(
|
||||||
|
ui,
|
||||||
|
Progress::determinate(sample.value),
|
||||||
|
sample.settle,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
body = body.push(frame::text_role(
|
body = body.push(frame::text_role(
|
||||||
ui,
|
ui,
|
||||||
|
|||||||
@@ -23,13 +23,17 @@
|
|||||||
//! 的规矩 2:core 的中文串只给日志和 CLI)。所以这里只搬**结构化字段**(阶段枚举、计数、字节),
|
//! 的规矩 2:core 的中文串只给日志和 CLI)。所以这里只搬**结构化字段**(阶段枚举、计数、字节),
|
||||||
//! 文案按 `Key` 现求——中文环境自然出中文,英文环境不需要任何中文字体。
|
//! 文案按 `Key` 现求——中文环境自然出中文,英文环境不需要任何中文字体。
|
||||||
|
|
||||||
use unlucky_presenter::dto::{
|
use unlucky_presenter::dto::{VersionKind, VersionSummary};
|
||||||
human_bytes, InstallProgressView, InstallStage, VersionKind, VersionSummary,
|
|
||||||
};
|
|
||||||
use unlucky_presenter::state::AppState;
|
use unlucky_presenter::state::AppState;
|
||||||
|
|
||||||
use unlucky_i18n::{Catalog, Key, Lang};
|
use unlucky_i18n::{Catalog, Key, Lang};
|
||||||
|
|
||||||
|
mod install;
|
||||||
|
|
||||||
|
pub use install::InstallView;
|
||||||
|
|
||||||
|
use crate::app::components::motion;
|
||||||
|
|
||||||
use super::options::{InstallDraft, OptionsView};
|
use super::options::{InstallDraft, OptionsView};
|
||||||
|
|
||||||
/// 取消安装之后那一句提示(M105)。
|
/// 取消安装之后那一句提示(M105)。
|
||||||
@@ -93,114 +97,6 @@ pub(super) const fn kind_label_key(kind: VersionKind) -> Key {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 一次安装的展示态(结构化字段 + 已求好的文案)。
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct InstallView {
|
|
||||||
/// 目标版本(= 选中的那一行)
|
|
||||||
pub version: String,
|
|
||||||
/// 阶段
|
|
||||||
pub stage: InstallStage,
|
|
||||||
/// 阶段词(`下载文件`)
|
|
||||||
pub stage_label: String,
|
|
||||||
/// 计数行(`128/512 文件`;未知时是「等第一个文件…」)
|
|
||||||
pub detail: String,
|
|
||||||
/// 速率 + 剩余时间(`1.2 MB/s · 剩 1m15s`;未知时 `None`)
|
|
||||||
pub rate: Option<String>,
|
|
||||||
/// 整体进度 0.0~1.0
|
|
||||||
pub ratio: f32,
|
|
||||||
/// 进度是否已知(`false` = 进度条画不确定态)
|
|
||||||
pub known: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InstallView {
|
|
||||||
/// 快照 → 展示态。
|
|
||||||
pub(super) fn of(progress: &InstallProgressView, catalog: Catalog) -> Self {
|
|
||||||
let known = progress.is_known();
|
|
||||||
let detail = if known {
|
|
||||||
catalog.render(
|
|
||||||
Key::DownloadProgress,
|
|
||||||
&[
|
|
||||||
("done", &progress.files_done.to_string()),
|
|
||||||
("total", &progress.files_total.to_string()),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
catalog.text(Key::DownloadProgressUnknown).to_owned()
|
|
||||||
};
|
|
||||||
let rate = if progress.speed_bps == 0 || progress.eta_secs == 0 {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
let eta = if progress.eta_secs >= 60 {
|
|
||||||
catalog.render(
|
|
||||||
Key::DownloadEtaMins,
|
|
||||||
&[
|
|
||||||
("mins", &(progress.eta_secs / 60).to_string()),
|
|
||||||
("secs", &format!("{:02}", progress.eta_secs % 60)),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
catalog.render(
|
|
||||||
Key::DownloadEtaSecs,
|
|
||||||
&[("secs", &progress.eta_secs.to_string())],
|
|
||||||
)
|
|
||||||
};
|
|
||||||
Some(catalog.render(
|
|
||||||
Key::DownloadRate,
|
|
||||||
&[
|
|
||||||
("speed", &format!("{}/s", human_bytes(progress.speed_bps))),
|
|
||||||
("eta", eta.as_str()),
|
|
||||||
],
|
|
||||||
))
|
|
||||||
};
|
|
||||||
|
|
||||||
Self {
|
|
||||||
version: progress.version.clone(),
|
|
||||||
stage: progress.stage,
|
|
||||||
stage_label: catalog.text(stage_key(progress.stage)).to_owned(),
|
|
||||||
detail,
|
|
||||||
rate,
|
|
||||||
ratio: progress.ratio(),
|
|
||||||
known,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 失败了(错误态要出可读文案 + 出口,不许静默)。
|
|
||||||
pub const fn failed(&self) -> bool {
|
|
||||||
matches!(self.stage, InstallStage::Failed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 收尾了(`Done` / `Failed` / `Cancelled`)。
|
|
||||||
pub fn terminal(&self) -> bool {
|
|
||||||
self.stage.is_terminal()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// **结束且没什么可说**的收尾:成功(`Done`)与取消(`Cancelled`)。
|
|
||||||
///
|
|
||||||
/// 这两种终态页面没有动作可给,留着只会让安装面板停在一个点不动的状态上(复审 p2 的根因)。
|
|
||||||
/// 失败**不在其中**:它还要给原因出口。
|
|
||||||
pub fn finished(&self) -> bool {
|
|
||||||
matches!(self.stage, InstallStage::Done | InstallStage::Cancelled)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 还在跑(「取消安装」只在这时给)。
|
|
||||||
pub fn running(&self) -> bool {
|
|
||||||
!self.stage.is_terminal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 阶段 → 文案 key(**不**用 `InstallStage::label()`:那是中文业务串)。
|
|
||||||
const fn stage_key(stage: InstallStage) -> Key {
|
|
||||||
match stage {
|
|
||||||
InstallStage::Planning => Key::DownloadStagePlanning,
|
|
||||||
InstallStage::Downloading => Key::DownloadStageDownloading,
|
|
||||||
InstallStage::Installing => Key::DownloadStageInstalling,
|
|
||||||
InstallStage::Registering => Key::DownloadStageRegistering,
|
|
||||||
InstallStage::Done => Key::DownloadStageDone,
|
|
||||||
InstallStage::Failed => Key::DownloadStageFailed,
|
|
||||||
InstallStage::Cancelled => Key::DownloadStageCancelled,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 下载页要画的东西(**全部字段都能从快照推出来**,页面自己不留状态)。
|
/// 下载页要画的东西(**全部字段都能从快照推出来**,页面自己不留状态)。
|
||||||
#[derive(Debug, Clone, PartialEq, Default)]
|
#[derive(Debug, Clone, PartialEq, Default)]
|
||||||
pub struct Download {
|
pub struct Download {
|
||||||
@@ -226,6 +122,12 @@ pub struct Download {
|
|||||||
pub type_rail_folded: bool,
|
pub type_rail_folded: bool,
|
||||||
/// 主版本桶(第二级侧栏的数据源;由 [`Self::rows`] 聚合,所以只含**当前分类**的桶)
|
/// 主版本桶(第二级侧栏的数据源;由 [`Self::rows`] 聚合,所以只含**当前分类**的桶)
|
||||||
pub buckets: Vec<super::group::Bucket>,
|
pub buckets: Vec<super::group::Bucket>,
|
||||||
|
/// **还在退场的行**(M305):已经不在这份模型的 `rows` 里,但淡出还没走完。
|
||||||
|
///
|
||||||
|
/// 为什么数据要留在这里:`motion::rows::drawn` 会吐出这些 id(删除项淡出后才真正移除),
|
||||||
|
/// 而它们的 `VersionRow` 已经从过滤后的 `rows` 里消失了——不留一份,那一路只能画空壳。
|
||||||
|
/// 池子由 [`Self::from_snapshot`] 在**同一次** update 里从快照重建(见那里的注释)。
|
||||||
|
pub leaving: Vec<VersionRow>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Download {
|
impl Download {
|
||||||
@@ -307,6 +209,36 @@ impl Download {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- M305:列表增删 / 进度插值的时间轴**在这里起跑**(update 侧,全页唯一入口)----
|
||||||
|
//
|
||||||
|
// 为什么是这一行:`pages/**` 里只有本函数在 update 侧被 `app/view.rs::model` 调用
|
||||||
|
// (`refresh_view` → `view::model` → 这里),而 iced 先算订阅表、后建视图
|
||||||
|
// (`app/subscription.rs` 的模块文档有源码行号)——在 `view` 里起跑的轨道当拍订阅不上。
|
||||||
|
//
|
||||||
|
// 观察的是**过滤后的行**(分类 / 搜索词已经生效):与视图画的是同一份清单,
|
||||||
|
// 所以「差分出的进/出」就是用户看到的那一次变化。
|
||||||
|
let ids: Vec<&str> = rows.iter().map(|row| row.id.as_str()).collect();
|
||||||
|
motion::rows::observe(&ids);
|
||||||
|
|
||||||
|
// 退场池:泵还在画、但已经不在这份 `rows` 里的行(数据从**整份快照**里取回来)。
|
||||||
|
let leaving = motion::rows::drawn()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|id| !rows.iter().any(|row| &row.id == id))
|
||||||
|
.filter_map(|id| {
|
||||||
|
state
|
||||||
|
.versions
|
||||||
|
.iter()
|
||||||
|
.find(|version| version.id == id)
|
||||||
|
.map(|version| VersionRow::of(version, catalog))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if let Some(view) = &install {
|
||||||
|
// 进度条同理:这条轨道也只在 update 侧起跑。未知进度不画条,也就不排轨道。
|
||||||
|
if view.known {
|
||||||
|
motion::progress::observe(view.version.as_str(), view.ratio);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
// 桶由**过滤后的行**聚合:第二级侧栏只列当前分类里真实出现的版本族。
|
// 桶由**过滤后的行**聚合:第二级侧栏只列当前分类里真实出现的版本族。
|
||||||
buckets: super::group::buckets(&rows),
|
buckets: super::group::buckets(&rows),
|
||||||
@@ -325,6 +257,7 @@ impl Download {
|
|||||||
selected_major: None,
|
selected_major: None,
|
||||||
// 折叠也是纯视图态;搜索重建时由 `rebuild_after_query` 前后各搬一次。
|
// 折叠也是纯视图态;搜索重建时由 `rebuild_after_query` 前后各搬一次。
|
||||||
type_rail_folded: false,
|
type_rail_folded: false,
|
||||||
|
leaving,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
//! 安装面板的**展示模型**(M305 从 `model.rs` 拆出;行数门禁 400)。
|
||||||
|
//!
|
||||||
|
//! 这一份只做「快照 → 要画的字段」的转换:文案走目录([`Catalog`]),进度取
|
||||||
|
//! [`InstallProgressView::ratio`](离散读数,插值在 `motion::progress`)。
|
||||||
|
//! 三个终态判据([`InstallView::failed`] / [`InstallView::terminal`] / [`InstallView::finished`])
|
||||||
|
//! 都在这里,页面只按它们分支——不许页面自己 match `InstallStage`(那会出现「面板说装完、
|
||||||
|
//! 列表说还在装」两套口径)。
|
||||||
|
|
||||||
|
use unlucky_i18n::{Catalog, Key};
|
||||||
|
use unlucky_presenter::dto::{human_bytes, InstallProgressView, InstallStage};
|
||||||
|
|
||||||
|
/// 一次安装的展示态(结构化字段 + 已求好的文案)。
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct InstallView {
|
||||||
|
/// 目标版本(= 选中的那一行)
|
||||||
|
pub version: String,
|
||||||
|
/// 阶段
|
||||||
|
pub stage: InstallStage,
|
||||||
|
/// 阶段词(`下载文件`)
|
||||||
|
pub stage_label: String,
|
||||||
|
/// 计数行(`128/512 文件`;未知时是「等第一个文件…」)
|
||||||
|
pub detail: String,
|
||||||
|
/// 速率 + 剩余时间(`1.2 MB/s · 剩 1m15s`;未知时 `None`)
|
||||||
|
pub rate: Option<String>,
|
||||||
|
/// 整体进度 0.0~1.0
|
||||||
|
pub ratio: f32,
|
||||||
|
/// 进度是否已知(`false` = 进度条画不确定态)
|
||||||
|
pub known: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InstallView {
|
||||||
|
/// 快照 → 展示态。
|
||||||
|
pub(crate) fn of(progress: &InstallProgressView, catalog: Catalog) -> Self {
|
||||||
|
let known = progress.is_known();
|
||||||
|
let detail = if known {
|
||||||
|
catalog.render(
|
||||||
|
Key::DownloadProgress,
|
||||||
|
&[
|
||||||
|
("done", &progress.files_done.to_string()),
|
||||||
|
("total", &progress.files_total.to_string()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
catalog.text(Key::DownloadProgressUnknown).to_owned()
|
||||||
|
};
|
||||||
|
let rate = if progress.speed_bps == 0 || progress.eta_secs == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let eta = if progress.eta_secs >= 60 {
|
||||||
|
catalog.render(
|
||||||
|
Key::DownloadEtaMins,
|
||||||
|
&[
|
||||||
|
("mins", &(progress.eta_secs / 60).to_string()),
|
||||||
|
("secs", &format!("{:02}", progress.eta_secs % 60)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
catalog.render(
|
||||||
|
Key::DownloadEtaSecs,
|
||||||
|
&[("secs", &progress.eta_secs.to_string())],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
Some(catalog.render(
|
||||||
|
Key::DownloadRate,
|
||||||
|
&[
|
||||||
|
("speed", &format!("{}/s", human_bytes(progress.speed_bps))),
|
||||||
|
("eta", eta.as_str()),
|
||||||
|
],
|
||||||
|
))
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
version: progress.version.clone(),
|
||||||
|
stage: progress.stage,
|
||||||
|
stage_label: catalog.text(stage_key(progress.stage)).to_owned(),
|
||||||
|
detail,
|
||||||
|
rate,
|
||||||
|
ratio: progress.ratio(),
|
||||||
|
known,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 失败了(错误态要出可读文案 + 出口,不许静默)。
|
||||||
|
pub const fn failed(&self) -> bool {
|
||||||
|
matches!(self.stage, InstallStage::Failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收尾了(`Done` / `Failed` / `Cancelled`)。
|
||||||
|
pub fn terminal(&self) -> bool {
|
||||||
|
self.stage.is_terminal()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **结束且没什么可说**的收尾:成功(`Done`)与取消(`Cancelled`)。
|
||||||
|
///
|
||||||
|
/// 这两种终态页面没有动作可给,留着只会让安装面板停在一个点不动的状态上(复审 p2 的根因)。
|
||||||
|
/// 失败**不在其中**:它还要给原因出口。
|
||||||
|
pub fn finished(&self) -> bool {
|
||||||
|
matches!(self.stage, InstallStage::Done | InstallStage::Cancelled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 还在跑(「取消安装」只在这时给)。
|
||||||
|
pub fn running(&self) -> bool {
|
||||||
|
!self.stage.is_terminal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 阶段 → 文案 key(**不**用 `InstallStage::label()`:那是中文业务串)。
|
||||||
|
const fn stage_key(stage: InstallStage) -> Key {
|
||||||
|
match stage {
|
||||||
|
InstallStage::Planning => Key::DownloadStagePlanning,
|
||||||
|
InstallStage::Downloading => Key::DownloadStageDownloading,
|
||||||
|
InstallStage::Installing => Key::DownloadStageInstalling,
|
||||||
|
InstallStage::Registering => Key::DownloadStageRegistering,
|
||||||
|
InstallStage::Done => Key::DownloadStageDone,
|
||||||
|
InstallStage::Failed => Key::DownloadStageFailed,
|
||||||
|
InstallStage::Cancelled => Key::DownloadStageCancelled,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
//! 本页的行是 44 的单行档,同样不加行距;[`ROW_GAP`] 只用在**行内**那三个格子的间距上。
|
//! 本页的行是 44 的单行档,同样不加行距;[`ROW_GAP`] 只用在**行内**那三个格子的间距上。
|
||||||
|
|
||||||
use iced::widget::{column, container, row, scrollable};
|
use iced::widget::{column, container, row, scrollable};
|
||||||
use iced::{Alignment, Element, Length, Padding};
|
use iced::{Alignment, Element, Length};
|
||||||
|
|
||||||
use unlucky_design::color::Role;
|
use unlucky_design::color::Role;
|
||||||
use unlucky_design::icons::IconKind;
|
use unlucky_design::icons::IconKind;
|
||||||
@@ -85,8 +85,16 @@ use super::install::{self, panel_state, PanelState};
|
|||||||
use super::model::{Download, VersionRow};
|
use super::model::{Download, VersionRow};
|
||||||
use super::states;
|
use super::states;
|
||||||
|
|
||||||
|
mod list;
|
||||||
mod tail;
|
mod tail;
|
||||||
|
|
||||||
|
// 本文件的两组判据(`rows/tests.rs` 是 M102/M107 留下的,M305 起**重新挂上**:
|
||||||
|
// 它此前一直没被 `mod` 声明 ⇒ 那两条用例根本没在跑,见报告「发现」一节)。
|
||||||
|
#[cfg(test)]
|
||||||
|
mod motion_tests;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
pub(crate) use tail::{menu_items, TailShare};
|
pub(crate) use tail::{menu_items, TailShare};
|
||||||
|
|
||||||
/// 列表列(三栏档与 chips 档共用一份)。
|
/// 列表列(三栏档与 chips 档共用一份)。
|
||||||
@@ -118,7 +126,7 @@ pub(super) fn content<'a>(
|
|||||||
} else {
|
} else {
|
||||||
list_height
|
list_height
|
||||||
};
|
};
|
||||||
list(ui, catalog, model, &visible, height)
|
list::list(ui, catalog, model, &visible, height)
|
||||||
};
|
};
|
||||||
|
|
||||||
// 列表与尾部面板之间**总是**有一条分隔线(三态一致):M123 的取证图里「选好了版本」
|
// 列表与尾部面板之间**总是**有一条分隔线(三态一致):M123 的取证图里「选好了版本」
|
||||||
@@ -252,38 +260,6 @@ pub(super) fn visible(model: &Download) -> Vec<&VersionRow> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 版本行列表(**独立滚动**:只有列表滚,安装面板钉在底部不跟着滚)。
|
|
||||||
fn list<'a>(
|
|
||||||
ui: &'a Ui,
|
|
||||||
catalog: Catalog,
|
|
||||||
model: &'a Download,
|
|
||||||
visible: &[&'a VersionRow],
|
|
||||||
height: Option<f32>,
|
|
||||||
) -> Element<'a, Message> {
|
|
||||||
let entries = column(
|
|
||||||
visible
|
|
||||||
.iter()
|
|
||||||
.map(|entry| version_row(ui, catalog, model, entry))
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
)
|
|
||||||
// M102:行距归零(见模块文档),只留横向留白
|
|
||||||
.padding(Padding::from([0.0, ui.space(Space::X2)]))
|
|
||||||
.width(Length::Fill);
|
|
||||||
|
|
||||||
scrollable(entries)
|
|
||||||
.direction(scrollable::Direction::Vertical(
|
|
||||||
scrollable::Scrollbar::new()
|
|
||||||
.width(frame::SCROLLBAR_WIDTH)
|
|
||||||
.scroller_width(frame::SCROLLBAR_WIDTH)
|
|
||||||
.margin(frame::SCROLLBAR_MARGIN),
|
|
||||||
))
|
|
||||||
// `Some` = 面板先保预算那一档按**整行**取高(三栏档 `tail::whole_rows` /
|
|
||||||
// chips 档 `tail::narrow_rows`);`None` = `Pinned`(面板矮)用 Fill。
|
|
||||||
.height(height.map_or(Length::Fill, Length::Fixed))
|
|
||||||
.style(frame::scroll_style(ui))
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 一行:`ListItem`(版本号,可点选)+ 类型 + 日期 + 安装图标按钮(**四样同一行**)。
|
/// 一行:`ListItem`(版本号,可点选)+ 类型 + 日期 + 安装图标按钮(**四样同一行**)。
|
||||||
///
|
///
|
||||||
/// 点版本号 = [`Message::SelectVersion`](页面内选中,不 dispatch);点尾随按钮 =
|
/// 点版本号 = [`Message::SelectVersion`](页面内选中,不 dispatch);点尾随按钮 =
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
//! 版本行的**行序**与**增删动效**(M305):键控列 + 逐行淡入淡出 / 高度收放 / 错位浮入。
|
||||||
|
//!
|
||||||
|
//! ## 这一层要同时满足三件事
|
||||||
|
//!
|
||||||
|
//! | 要求 | 落点 |
|
||||||
|
//! |---|---|
|
||||||
|
//! | 删除项**淡出后**才真正移除 | 行序由泵给([`motion::rows::drawn`]),退场行还在序里、可见度一路走 0 |
|
||||||
|
//! | **不许整表瞬间重排闪跳** | ①行序不动(退出行钉在原位)②高度逐帧收到 0,下面那些行是滑上来的 |
|
||||||
|
//! | 增删**只影响变化项** | 列是 `keyed::Column`(按 id 做连续匹配),没变的行 `Tree` 节点原样复用 |
|
||||||
|
//!
|
||||||
|
//! ## 为什么是键控列(不是普通 `column`)
|
||||||
|
//!
|
||||||
|
//! iced 的 `Column` 按**下标**比对子节点:在第 3 行前面插一行,第 3 行起的子树全部错位重对一遍
|
||||||
|
//! (hover / 焦点 / 滚动锚点都跟着串台)。`keyed::Column` 用 key(= 版本 id)做
|
||||||
|
//! `diff_children_custom_with_search`,**没变的 id 直接命中同一个 `Tree` 节点**——这才谈得上
|
||||||
|
//! 「只影响变化项」。判据在 `tests/list_motion.rs`(变异:把 key 换成下标 ⇒ 那条红)。
|
||||||
|
//!
|
||||||
|
//! ## 一行为什么要套两层
|
||||||
|
//!
|
||||||
|
//! 1. [`motion::revealed`]:裁切框高 = `row_height × 可见度`,内容仍按 `row_height` 排版
|
||||||
|
//! (否则半行高会让行内文字重排「沸腾」,`motion/clip.rs` 的模块文档记的正是这条);
|
||||||
|
//! 2. [`motion::faded`]:整份色板按可见度预合成到卡片底色上(D27:不许把 α 交给渲染器)。
|
||||||
|
//!
|
||||||
|
//! 两层**恒定存在**(静息时裁切高 = 内容高、色板不做预合成),所以动画起止不会换掉树的形状
|
||||||
|
//! ——`motion::shifted` 的同一条纪律:只在动画期间套一层,等于每次动画起止各整棵重建一次。
|
||||||
|
|
||||||
|
use std::hash::{Hash as _, Hasher as _};
|
||||||
|
|
||||||
|
use iced::widget::{keyed, scrollable};
|
||||||
|
use iced::{Element, Length, Padding};
|
||||||
|
|
||||||
|
use unlucky_design::elevation::MaterialLayer;
|
||||||
|
use unlucky_design::spacing::Space;
|
||||||
|
use unlucky_i18n::Catalog;
|
||||||
|
|
||||||
|
use crate::app::components::motion;
|
||||||
|
use crate::theme::Ui;
|
||||||
|
|
||||||
|
use super::super::super::frame;
|
||||||
|
use super::super::super::Message;
|
||||||
|
use super::super::model::{Download, VersionRow};
|
||||||
|
|
||||||
|
/// 行高:`ListItem` 的单行档(令牌 `row_height`,桌面密集档 44)。
|
||||||
|
///
|
||||||
|
/// **只用来算裁切框**:内容的排版高恒为它,逐帧变的只有裁切框(见模块文档)。
|
||||||
|
pub(super) fn row_height(ui: &Ui) -> f32 {
|
||||||
|
ui.metrics().row_height
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一行的键控 id:`keyed::Column` 要求 `Key: Copy + PartialEq`,所以把版本 id 折成一个 `u64`。
|
||||||
|
///
|
||||||
|
/// 只要求**进程内稳定**(同一 id 每帧算出同一个数):`DefaultHasher` 不保证跨进程/跨版本一致,
|
||||||
|
/// 而这里要的正是「同一份视图前后两帧能不能认出是同一行」。
|
||||||
|
fn row_key(id: &str) -> u64 {
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
id.hash(&mut hasher);
|
||||||
|
hasher.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 版本行列表(**独立滚动**:只有列表滚,安装面板钉在底部不跟着滚)。
|
||||||
|
///
|
||||||
|
/// `visible` 是在册的行(分类 + 桶过滤之后),`model.leaving` 是**还在退场**的行(已经不在模型
|
||||||
|
/// 里、但淡出没走完)。画哪一行、按什么序画,由 [`motion::rows::drawn`] 说了算。
|
||||||
|
pub(super) fn list<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
catalog: Catalog,
|
||||||
|
model: &'a Download,
|
||||||
|
visible: &[&'a VersionRow],
|
||||||
|
height: Option<f32>,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
scrollable(rows_column(ui, catalog, model, visible))
|
||||||
|
.direction(scrollable::Direction::Vertical(
|
||||||
|
scrollable::Scrollbar::new()
|
||||||
|
.width(frame::SCROLLBAR_WIDTH)
|
||||||
|
.scroller_width(frame::SCROLLBAR_WIDTH)
|
||||||
|
.margin(frame::SCROLLBAR_MARGIN),
|
||||||
|
))
|
||||||
|
// `Some` = 面板先保预算那一档按**整行**取高(三栏档 `tail::whole_rows` /
|
||||||
|
// chips 档 `tail::narrow_rows`);`None` = `Pinned`(面板矮)用 Fill。
|
||||||
|
.height(height.map_or(Length::Fill, Length::Fixed))
|
||||||
|
.style(frame::scroll_style(ui))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 键控列本身(滚动区**不含**在里面):单测要直接拿这一层去 `Tree::diff` 验节点复用。
|
||||||
|
pub(super) fn rows_column<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
catalog: Catalog,
|
||||||
|
model: &'a Download,
|
||||||
|
visible: &[&'a VersionRow],
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let row_height = row_height(ui);
|
||||||
|
// 画哪些行、什么序:**泵说了算**(退场行还钉在原位)。但泵没被喂过任何清单时(截图 harness
|
||||||
|
// 的 `ready` 演示、单测直接构造模型、将来某个新入口忘了接线)它一条都给不出——那时**退回模型
|
||||||
|
// 自己的顺序**。少了这一条兜底,这些路径的列表会整块空掉(M305 出图时实测抓到过一次:
|
||||||
|
// 计数头写「8 versions」而下面一行都没有)。
|
||||||
|
let drawn = motion::rows::drawn();
|
||||||
|
let drawn: Vec<String> = if drawn.is_empty() {
|
||||||
|
visible.iter().map(|row| row.id.clone()).collect()
|
||||||
|
} else {
|
||||||
|
drawn
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut entries = keyed::Column::new()
|
||||||
|
// M102:行距归零(见 `rows.rs` 模块文档),只留横向留白
|
||||||
|
.padding(Padding::from([0.0, ui.space(Space::X2)]))
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
|
for id in &drawn {
|
||||||
|
// 数据池 = 在册 + 退场。找不到就跳过(快照里连版本都没了:没有数据可画)。
|
||||||
|
let Some(row) = visible
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.chain(model.leaving.iter())
|
||||||
|
.find(|row| &row.id == id)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let sample = motion::rows::frame(id);
|
||||||
|
let visibility = sample.visibility.clamp(0.0, 1.0);
|
||||||
|
// 收尾遮罩:与卡片底色同色的 quad,α = 1 − 可见度。静息(1.0)时**不画**(`None`),
|
||||||
|
// 于是末帧与静息逐像素相同。为什么不是「把 α 交给行内容」、为什么遮罩必须自成一层,
|
||||||
|
// 见 `motion/reveal.rs` 的模块文档(两件事都有实测记录)。
|
||||||
|
let veil = (visibility < 1.0).then(|| {
|
||||||
|
let card = ui.layer(MaterialLayer::Card);
|
||||||
|
iced::Color {
|
||||||
|
a: (1.0 - visibility).clamp(0.0, 1.0),
|
||||||
|
..card
|
||||||
|
}
|
||||||
|
});
|
||||||
|
entries = entries.push(
|
||||||
|
row_key(id),
|
||||||
|
motion::revealed(
|
||||||
|
super::version_row(ui, catalog, model, row),
|
||||||
|
row_height * visibility,
|
||||||
|
row_height,
|
||||||
|
sample.offset,
|
||||||
|
veil,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.into()
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
//! 列表增删动效的**页面侧**判据(M305):只影响变化项、`NONE` 落到最终态、接线没被悄悄摘掉。
|
||||||
|
//!
|
||||||
|
//! 动效的算术(差分 / 时钟 / 收束)在 `components/motion/tests/{rows,progress}.rs`;这里管的是
|
||||||
|
//! **页面怎么把采样结果接到树上**——那正是「动效把重排成本乘上帧数」这个风险所在。
|
||||||
|
//!
|
||||||
|
//! ## 变异账(T41:只改一处 ⇒ 期望红在哪条用例哪句断言)
|
||||||
|
//!
|
||||||
|
//! | 变异(只改这一处) | 期望红在哪 |
|
||||||
|
//! |---|---|
|
||||||
|
//! | `rows/list.rs` 把 `keyed::Column` 换回 `Column`(按**下标**比对) | `inserting_a_row_keeps_every_unchanged_rows_node`(a/b/c 的树节点会全换一遍) |
|
||||||
|
//! | `rows/list.rs` 的 `row_key` 里改成 `0`(key 恒等) | 同一条用例(新行会顶掉 a 的节点) |
|
||||||
|
//! | `pump/rows.rs` 的 `observe_rows` 删掉 `is_off()` 分支 | `no_scale_lands_on_the_final_state_without_frames`(`needs_frames()` 会为真) |
|
||||||
|
//! | `rows/list.rs` 里不再按 `motion::rows::drawn()` 取行(改回遍历 `visible`) | `the_list_is_fed_by_the_pump_order` |
|
||||||
|
|
||||||
|
use std::any::Any;
|
||||||
|
|
||||||
|
use iced::advanced::widget::{tree, Tree};
|
||||||
|
use unlucky_design::motion::MotionScale;
|
||||||
|
use unlucky_i18n::Lang;
|
||||||
|
use unlucky_presenter::dto::VersionKind;
|
||||||
|
|
||||||
|
use crate::app::components::motion;
|
||||||
|
use crate::pages::download::model::{Category, Download, VersionRow};
|
||||||
|
use crate::theme::{Families, Ui};
|
||||||
|
|
||||||
|
use super::{list, visible};
|
||||||
|
|
||||||
|
/// 与本页其余用例同一个配色口径(浅色 + Windows 字体栈)。
|
||||||
|
fn ui() -> Ui {
|
||||||
|
Ui::with_platform(
|
||||||
|
unlucky_design::color::SchemeFlavor::Light,
|
||||||
|
unlucky_design::typography::Platform::Windows,
|
||||||
|
Families {
|
||||||
|
text: "Segoe UI",
|
||||||
|
display: "Segoe UI",
|
||||||
|
mono: "Consolas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一行(只填画得出来的字段:文案走目录,这里给空串)。
|
||||||
|
fn row(id: &str) -> VersionRow {
|
||||||
|
VersionRow {
|
||||||
|
id: id.to_string(),
|
||||||
|
released: "2026-09-20".to_string(),
|
||||||
|
kind: VersionKind::Release,
|
||||||
|
installed: false,
|
||||||
|
supporting: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model(ids: &[&str]) -> Download {
|
||||||
|
Download {
|
||||||
|
rows: ids.iter().map(|id| row(id)).collect(),
|
||||||
|
category: Category::All,
|
||||||
|
loaded: true,
|
||||||
|
..Download::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一行子树的**状态盒指针**(复用 = 同一个 `Box`,重建 = 换一个)。
|
||||||
|
///
|
||||||
|
/// 这就是「帧内节点标识」的可观测形态:`Tree` 只有 `tag` / `state` / `children` 三样,
|
||||||
|
/// 其中 `state` 是那个 `Box`——它被重建,行内的 hover / 焦点 / 滚动锚点就都丢了。
|
||||||
|
///
|
||||||
|
/// 行本身是 `Row` / `Revealed` 这种**无状态**容器(`State::None`),所以往下找到第一个
|
||||||
|
/// 带状态的节点(行里那个 `button`)当标识:同一行的树形状每帧一样,落点也就一样。
|
||||||
|
fn identity(tree: &Tree) -> Option<*const dyn Any> {
|
||||||
|
match &tree.state {
|
||||||
|
tree::State::Some(state) => Some(state.as_ref() as *const dyn Any),
|
||||||
|
tree::State::None => tree.children.iter().find_map(identity),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按当前泵状态构建**键控列**(与页面走同一个函数,跳过外层滚动区)。
|
||||||
|
fn column_of<'a>(ui: &'a Ui, model: &'a Download) -> iced::Element<'a, crate::pages::Message> {
|
||||||
|
let rows = visible(model);
|
||||||
|
list::rows_column(ui, Lang::En.catalog(), model, &rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **插一行在前头,a/b/c 的树节点一个都不许重建**(这就是「只影响变化项」)。
|
||||||
|
///
|
||||||
|
/// 抓得住:把键控列换成普通 `Column`(按下标比对 ⇒ a 会去对新建那一行的状态);
|
||||||
|
/// 或者 key 不再由 id 决定(新行会顶掉别人的状态盒)。
|
||||||
|
/// 抓不住:帧内的**布局**成本(逐行重排一次是 iced 的布局算法,不是这条用例的范围)。
|
||||||
|
#[test]
|
||||||
|
fn inserting_a_row_keeps_every_unchanged_rows_node() {
|
||||||
|
motion::reset();
|
||||||
|
motion::configure_for_test(MotionScale::STANDARD, None);
|
||||||
|
let ui = ui();
|
||||||
|
|
||||||
|
// 起点:a / b / c
|
||||||
|
motion::rows::observe(&["a", "b", "c"]);
|
||||||
|
let before = model(&["a", "b", "c"]);
|
||||||
|
let element = column_of(&ui, &before);
|
||||||
|
let mut live = Tree::new(element.as_widget());
|
||||||
|
let ids = motion::rows::drawn();
|
||||||
|
assert_eq!(ids, vec!["a", "b", "c"], "前提:画的就是这三行");
|
||||||
|
let states: Vec<_> = live.children.iter().filter_map(identity).collect();
|
||||||
|
assert_eq!(states.len(), 3, "前提:三行三个状态盒");
|
||||||
|
|
||||||
|
// 变化:x 插到最前面(a/b/c 全体下标 +1)
|
||||||
|
assert!(motion::rows::observe(&["x", "a", "b", "c"]));
|
||||||
|
let after = model(&["x", "a", "b", "c"]);
|
||||||
|
let next = column_of(&ui, &after);
|
||||||
|
let ids = motion::rows::drawn();
|
||||||
|
assert_eq!(
|
||||||
|
ids,
|
||||||
|
vec!["x", "a", "b", "c"],
|
||||||
|
"新行必须落在它该在的位置(不是追加到末尾)"
|
||||||
|
);
|
||||||
|
live.diff(next.as_widget());
|
||||||
|
let states_after: Vec<_> = live.children.iter().filter_map(identity).collect();
|
||||||
|
assert_eq!(states_after.len(), 4);
|
||||||
|
|
||||||
|
// 未变的三行:同一个状态盒(下标挪了但节点没重建)
|
||||||
|
for (offset, id) in ["a", "b", "c"].iter().enumerate() {
|
||||||
|
assert!(
|
||||||
|
std::ptr::eq(states_after[offset + 1], states[offset]),
|
||||||
|
"{id} 的树节点被重建了:键控列没生效(下标比对会让整表都换一遍)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 新行是新的节点(一个旧盒子都没被它抢走)
|
||||||
|
assert!(
|
||||||
|
!states
|
||||||
|
.iter()
|
||||||
|
.any(|state| std::ptr::eq(*state, states_after[0])),
|
||||||
|
"新行的节点不该复用任何旧行的状态盒"
|
||||||
|
);
|
||||||
|
motion::reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删一行时,**退场那一行还在列里**(淡出没走完),而且没变的行不动。
|
||||||
|
#[test]
|
||||||
|
fn a_removed_row_is_still_in_the_column_while_it_fades() {
|
||||||
|
motion::reset();
|
||||||
|
motion::configure_for_test(MotionScale::STANDARD, None);
|
||||||
|
let ui = ui();
|
||||||
|
|
||||||
|
motion::rows::observe(&["a", "b", "c"]);
|
||||||
|
assert!(motion::rows::observe(&["a", "c"]));
|
||||||
|
let model = model(&["a", "c"]);
|
||||||
|
let ids = motion::rows::drawn();
|
||||||
|
assert_eq!(ids, vec!["a", "b", "c"], "退场行必须还在画的顺序里");
|
||||||
|
|
||||||
|
// 退场行的数据从 `leaving` 池里来:没进池子就画不出来(这一行就是那条接线的判据)
|
||||||
|
let mut with_pool = model.clone();
|
||||||
|
with_pool.leaving = vec![row("b")];
|
||||||
|
let element = column_of(&ui, &with_pool);
|
||||||
|
let tree = Tree::new(element.as_widget());
|
||||||
|
assert_eq!(
|
||||||
|
tree.children.len(),
|
||||||
|
3,
|
||||||
|
"列里必须还是三行(b 正在淡出),否则下面那些行会当场跳上来"
|
||||||
|
);
|
||||||
|
motion::reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **泵没被喂过清单时,列表照样画满**(截图 harness 的 `ready` 演示 / 单测直接构造模型)。
|
||||||
|
///
|
||||||
|
/// 抓得住:`list` 里只认 `motion::rows::drawn()` 而没有兜底 —— 那些路径会画出一个空列表,
|
||||||
|
/// 而计数头照样写「8 versions」(M305 出图时实测踩到:图上是空的)。
|
||||||
|
#[test]
|
||||||
|
fn a_model_built_without_the_pump_still_draws_every_row() {
|
||||||
|
motion::reset();
|
||||||
|
let ui = ui();
|
||||||
|
let untouched = model(&["a", "b", "c"]);
|
||||||
|
let element = column_of(&ui, &untouched);
|
||||||
|
let tree = Tree::new(element.as_widget());
|
||||||
|
assert_eq!(
|
||||||
|
tree.children.len(),
|
||||||
|
3,
|
||||||
|
"泵还没见过任何清单时,列表必须退回模型自己的顺序"
|
||||||
|
);
|
||||||
|
motion::reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列表的行序**由泵给**(不是直接遍历模型):源码结构断言,盯着接线别被悄悄改回去。
|
||||||
|
///
|
||||||
|
/// 抓得住:把 `motion::rows::drawn()` 换回 `visible` 的直接遍历(退场行会缺位、淡出就没了)。
|
||||||
|
/// 抓不住:drawn 的内容对不对(那是 `components/motion/tests/rows.rs` 的事)。
|
||||||
|
#[test]
|
||||||
|
fn the_list_is_fed_by_the_pump_order() {
|
||||||
|
let source = include_str!("list.rs");
|
||||||
|
for needle in [
|
||||||
|
"motion::rows::drawn()",
|
||||||
|
"motion::rows::frame(",
|
||||||
|
"keyed::Column::new()",
|
||||||
|
"motion::revealed(",
|
||||||
|
"leaving",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
source.contains(needle),
|
||||||
|
"`rows/list.rs` 里少了 `{needle}`:列表的行序/动效接线被摘掉了"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `MotionScale::NONE`:列表变化**当场就是最终态**,而且一帧都不要。
|
||||||
|
#[test]
|
||||||
|
fn no_scale_lands_on_the_final_state_without_frames() {
|
||||||
|
motion::reset();
|
||||||
|
motion::configure_for_test(MotionScale::NONE, None);
|
||||||
|
|
||||||
|
motion::rows::observe(&["a"]);
|
||||||
|
assert!(!motion::rows::observe(&["a", "b"]));
|
||||||
|
assert!(!motion::needs_frames(), "关掉动效 ⇒ 一帧都不订阅");
|
||||||
|
assert_eq!(motion::rows::drawn(), vec!["a", "b"], "不许留退场幽灵");
|
||||||
|
|
||||||
|
motion::rows::observe(&["b"]);
|
||||||
|
assert_eq!(motion::rows::drawn(), vec!["b"]);
|
||||||
|
for id in ["a", "b"] {
|
||||||
|
let frame = motion::rows::frame(id);
|
||||||
|
assert_eq!(frame.visibility, 1.0, "{id} 必须是最终态(不许停在中间)");
|
||||||
|
assert_eq!(frame.offset, 0.0, "{id} 不许留偏移");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 进度条同理:值严格等于目标、没有收束
|
||||||
|
motion::progress::observe("k", 0.4);
|
||||||
|
assert!(!motion::progress::observe("k", 1.0));
|
||||||
|
let progress = motion::progress::frame("k", 1.0);
|
||||||
|
assert_eq!(progress.value, 1.0);
|
||||||
|
assert_eq!(progress.settle, 0.0);
|
||||||
|
motion::reset();
|
||||||
|
}
|
||||||
@@ -21,7 +21,8 @@ fn the_version_row_still_fits_on_one_line_at_the_floor() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 版本号那一格在最窄情况下还剩多少
|
// 版本号那一格在最窄情况下还剩多少
|
||||||
let label_room = floor - LIST_KIND_WIDTH - LIST_DATE_WIDTH - ROW_ICON_BUTTON - 3.0 * ROW_GAP.dp();
|
let label_room =
|
||||||
|
floor - LIST_KIND_WIDTH - LIST_DATE_WIDTH - ROW_ICON_BUTTON - 3.0 * ROW_GAP.dp();
|
||||||
assert!(
|
assert!(
|
||||||
label_room >= 176.0,
|
label_room >= 176.0,
|
||||||
"版本号那一格只剩 {label_room},`1.21.9-pre3`(11 字符)会被切"
|
"版本号那一格只剩 {label_room},`1.21.9-pre3`(11 字符)会被切"
|
||||||
@@ -36,6 +37,12 @@ fn the_version_row_still_fits_on_one_line_at_the_floor() {
|
|||||||
fn the_search_bar_is_actually_mounted() {
|
fn the_search_bar_is_actually_mounted() {
|
||||||
let source = include_str!("../rows.rs");
|
let source = include_str!("../rows.rs");
|
||||||
assert!(source.contains("search_bar("), "搜索条没装");
|
assert!(source.contains("search_bar("), "搜索条没装");
|
||||||
assert!(source.contains("Message::SetVersionQuery"), "搜索条不产生查询消息");
|
assert!(
|
||||||
assert!(source.contains("Message::ClearVersionQuery"), "搜索条没有清空入口");
|
source.contains("Message::SetVersionQuery"),
|
||||||
|
"搜索条不产生查询消息"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
source.contains("Message::ClearVersionQuery"),
|
||||||
|
"搜索条没有清空入口"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -17,12 +17,16 @@ use super::*;
|
|||||||
/// 它只看字面出现,用宏拼 key 名能骗过它。
|
/// 它只看字面出现,用宏拼 key 名能骗过它。
|
||||||
#[test]
|
#[test]
|
||||||
fn every_download_key_is_referenced_by_the_page() {
|
fn every_download_key_is_referenced_by_the_page() {
|
||||||
const SOURCES: [&str; 12] = [
|
const SOURCES: [&str; 14] = [
|
||||||
include_str!("../../download.rs"),
|
include_str!("../../download.rs"),
|
||||||
include_str!("../model.rs"),
|
include_str!("../model.rs"),
|
||||||
// M107:分类枚举搬进 `model/category.rs`(`Category::label_key` 在那儿,漏扫会误判死 key)
|
// M107:分类枚举搬进 `model/category.rs`(`Category::label_key` 在那儿,漏扫会误判死 key)
|
||||||
include_str!("../model/category.rs"),
|
include_str!("../model/category.rs"),
|
||||||
|
// M305:安装面板的展示模型搬进 `model/install.rs`(`stage_key` 在那儿)
|
||||||
|
include_str!("../model/install.rs"),
|
||||||
include_str!("../rows.rs"),
|
include_str!("../rows.rs"),
|
||||||
|
// M305:行序与增删动效(`rows/list.rs`;后续若在那儿落 key 不会漏)
|
||||||
|
include_str!("../rows/list.rs"),
|
||||||
// M107:两级侧栏与分组规则(`VersionRailTitle` / `VersionMajorAll` 的落点)
|
// M107:两级侧栏与分组规则(`VersionRailTitle` / `VersionMajorAll` 的落点)
|
||||||
include_str!("../rails.rs"),
|
include_str!("../rails.rs"),
|
||||||
// M125:主版本栏搬进 `rails/major.rs`(`VersionMajorTitle` / `VersionMajorAll` 的落点)
|
// M125:主版本栏搬进 `rails/major.rs`(`VersionMajorTitle` / `VersionMajorAll` 的落点)
|
||||||
|
|||||||
Reference in new issue
Block a user