Merge branch 'feat/m305-motion2-list-add-remove-and-progres'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-13 22:31:09 +08:00
commit b9c5f36fa5
24 files changed
+2344 -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,54 @@
//! **颜色预合成**(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`)。它不改几何、不改厚度,所以**不引起重排**。
//!
//! ## 把 α 交给渲染器的地方(口径写在这里,别处不许再有第二种说法)
//!
//! | 场景 | 做法 | 产物透明度 | 是不是动画 |
//! |---|---|---|---|
//! | 颜色插值(进度条收束、遮罩浓度、禁用层) | [`mix`] / `with_alpha(α).over(底)` | **恒 1.0** | 是 |
//! | **一整棵子树淡出**(列表行) | `motion/reveal.rs` 的收尾遮罩:与**画布同色**的 quad,α = 1 − 可见度,且**必须自成一层** | 动画期间 < 1,**静息帧严格不画** | 是(唯一一处**动画中间帧**的 α) |
//! | **滚动视口底部的淡出带**(M227) | `pages/download/options/fade.rs::viewport_fade`:12 条 1px 的**画布色**块,α 从 `(1/12)^1.6 ≈ 0.019` 递到 1.0,经 `frame::filled` 进 `container::Style` | 常驻 α∈(0,1] | **否**(静态渐变) |
//! | 面板层(Card / Overlay) | `theme.rs::layer` × `panel_alpha`(`MaterialLayer::allows_translucency`) | CPU 档被强制 **1.0**;GPU 档开背景图时可 < 1 | 否(静态) |
//!
//! 两处**静态** α 为什么不归 D27 管(也**不在**本文「`mix` 恒不透明」那条口径内):
//! ①`viewport_fade` 是**常驻**的发现性提示——iced 0.14 没有渐变原语,12 条 1px 实色块是它的替代品,
//! 而且它的颜色恒等于它盖住的画布色(`ui.layer(Canvas)`),浅色/深色都不新增颜色;
//! ②`panel_alpha` 是「GPU 档开背景图」那条线自己的决定(`elevation.rs` 的 `allows_translucency`),
//! CPU 档由 `app` 侧强制 1.0,与动效无关。这一支属**另一条线**,本文不覆盖。
//!
//! 为什么子树淡出做不到「预合成」:预合成要求先知道被盖住的像素(`content.with_alpha(α).over(底)`),
//! 而「一行的内容」在 `Element` 里是黑盒;唯一可行的替代是把整份 `Scheme` 预合成后换一个 `Ui`
//! 传下去,但那要求淡化的 `Ui` 活得比 `Element<'a>` 久——视图函数里现造的 `Ui` 撑不到返回,
//! 泄漏成 `'static` 则是每帧每行泄漏一份 49 色方案(长会话无界增长)。
//!
//! ⚠️ **守卫盲区(r1 复审 P2-4 要求写清)**:`interaction/mod.rs` 那条「中间帧也必须不透明」的
//! 用例只扫**状态层取色**,**扫不到** `motion/reveal.rs::draw` 递出去的遮罩(那一处是渲染器的
//! `fill_quad`,不经过任何 `Style`)。所以「遮罩的 α 只在动画期间出现、静息帧一个 quad 都不画」
//! 这条口径改由 `pages/download/rows/motion_tests.rs` 的结构断言 + 出图基线盯着
//! (静息帧与改造前逐像素相同,见 `DevDocs/artifacts/m305/`)。
use unlucky_design::color::Color;
/// 两个**不透明**颜色之间按 `t` 的插值。
///
/// 用在「进度条收束」这类没有背景色可叠的地方:`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,250 @@
//! **进度插值**的轨道(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;
}
// **没有轨道但目标没变** = 这条事件什么也没带来:不许为了「再更新一次」插一条
// `from == to` 的空轨道。少了这一行,脉冲走完之后**每一条 stage 事件都会再闪一次**
// (`ratio()` 在最后一个文件下完就恒为 1.0,而 Installing/Registering 阶段每条事件都
// 会走一遍 `observe`),既违反「一次收束不循环」,也空烧 200ms + 150ms 的帧
// ——这正是 M305 r1 复审的 P1-1。
if self.progress.track.is_none() && (target - self.progress.shown).abs() < f32::EPSILON {
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,366 @@
//! **列表行增删**的轨道(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)。
///
/// 必须取**当前采样**而不是 `track.from`:进场走到一半就被删的行,`from` 是 0(它从不可见
/// 开始),拿它当退场起点会得到「0 → 0」——可见度与高度当拍归零、下面整列上跳,正是任务①
/// 禁的闪跳。退场中途回来的那一侧本来就走采样,两个方向必须同一条口径(M305 r1 P2-1)。
fn visibility_of(&self, id: &str, now_ms: f64) -> f32 {
self.tracks
.iter()
.find(|(tracked, _)| tracked == id)
.map_or(1.0, |(_, track)| track.sample(now_ms - track.start_ms))
}
/// 换一条轨道(同 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 now = self.elapsed_ms();
// ① 离开的:用**旧顺序**里的位置算错位(用户看到的原本次序)。
let leaving: Vec<(String, usize)> = self
.rows
.current
.iter()
.enumerate()
.filter(|(_, id)| !ids.contains(&id.as_str()))
.map(|(index, id)| (id.clone(), index))
.collect();
let left = leaving.len();
for (id, index) in leaving {
let from = self.rows.visibility_of(&id, now);
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 arrived = arriving.len();
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);
}
// ③ **纯重排**(同一批 id 换了顺序):没有进、也没有出,所以一条轨道都不建——
// 而 `advance` 在没轨道时早退、`expire` 也就不跑,`order` 会永远停在旧序
// (`Rows::expire` 是它唯一的归位处)。所以这里当场把画的顺序对齐真值
// (M305 r1 P2-3;今天下载页没有排序切换,属机制级隐患)。
self.rows.current = ids.iter().map(|id| (*id).to_owned()).collect();
if left == 0 && arrived == 0 {
self.rows.order.clone_from(&self.rows.current);
}
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,253 @@
//! **纵向裁切容器**(M305):占的格子高可以逐帧变,**内容排版高不变**。
//!
//! ## 为什么需要它(与 `clip.rs` 是同一课,换了一个轴)
//!
//! 列表增删要「高度也一起收/放」:不收,退场行删掉的那一帧下面所有行整体上移一格 = **整表瞬间
//! 重排闪跳**(任务里点名禁止);收,就必须让一行的高从 44 走到 0。而直接把 44 喂给行内容会让
//! 它按「半行高」重新排版(文字折行 / 尾部按钮挤掉),那 150ms 里行内会「沸腾」——`clip.rs`
//! 的模块文档记的正是这件事(M257 的取证图里空态文案在过渡中折成两行)。
//!
//! 所以两件事拆开:
//!
//! | 谁 | 用多大的高 |
//! |---|---|
//! | 子内容的**排版** | `content_height`(整段过渡里不变 ⇒ 不重排) |
//! | 自己占的**格子**(= 裁切框) | `visible_height`(逐帧变,由行的可见度给) |
//!
//! ## 绘制:裁切 + **一层与画布同色的收尾遮罩**
//!
//! 裁切走 `Renderer::with_layer(bounds)`(tiny-skia 是 `push_clip`,wgpu 是同族的层栈):超出的
//! 部分画不出来——「收起到 0」在像素上就是「一格都不占」。
//!
//! 淡出走 [`revealed`] 的 `veil` 参数:一层**与脚下画布同色**(`MaterialLayer::Canvas`)的 quad,
//! `α = 1 − 可见度`,**且必须自成一层**。为什么是 α 而不是预合成、为什么必须自成一层:
//!
//! | 问题 | 答案 |
//! |---|---|
//! | 为什么不用预合成(`with_alpha(α).over(底)`,D27 的正统口径) | 预合成要先知道被盖住的像素,而「一行的内容」在 `Element` 里是黑盒;替代方案(把整份 `Scheme` 预合成后传一个 `Ui` 下去)要求那个 `Ui` 活得比 `Element<'a>` 久——视图函数里造不出来,泄漏成 `'static` 则是每帧每行泄漏一份 49 色方案 |
//! | 为什么必须 `with_layer` | tiny-skia 的绘制按「四边形 → 三角 → 图 → 文字」分批(`iced_tiny_skia/src/lib.rs::present`),同层里文字永远排在四边形之后。第一版把遮罩塞进当前层:红色 α=0.3 的遮罩实测**一个文字像素都没盖住**(整图 0 个偏红像素);改成子层后同一实验 422 个偏红像素、正好落在动的那两行(y 262..307) |
//! | 静息帧 | `veil = None` ⇒ **一个 quad 都不画**,与改造前逐像素相同(出图基线与 M305 之前同源) |
//! | 口径归属 | 这是全仓**唯一一处「动画中间帧」的 α**:只有退场/入场那 150~200ms 里出现,静息帧严格不画。规则的真源写在 `motion/fade.rs` 的模块文档,那里同时登记了两处**静态**的 α(不在本文口径内):`options/fade.rs::viewport_fade` 的常驻渐变、`ui.layer()` 的 `panel_alpha`(GPU 档开背景图时)。`interaction/mod.rs` 的 α 守卫只扫状态层取色、**扫不到**这里(不经过任何 `Style`)——盲区已登记在 `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,215 @@
//! **进度插值**的守卫(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` 里删掉「没有轨道但目标没变 ⇒ return false」那一段 | `a_repeated_reading_after_the_pulse_does_not_fire_it_again`(r1 P1-1) |
//! | `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;
}
/// **同一条读数再报一次不许再排一次收束**(r1 P1-1,产品可达)。
///
/// 场景:最后一个文件下完之后 `ratio()` 恒为 1.0,而 `Installing` / `Registering` 阶段每条 stage
/// 事件都会再走一遍 `observe`(`host/install.rs:216,239` → refresh_view → `from_snapshot`)。
/// 少了那条短路,每来一条事件就插一条 `from == to` 的空轨道 ⇒ 200ms 后**再闪一次** 150ms 脉冲,
/// 既违反「一次收束不循环」,也空烧 350ms 的帧。
///
/// 抓得住:删掉 `observe_progress` 里那条 `track.is_none() && 目标没变` 的短路。
/// 抓不住:脉冲本身的观感(出图负责)。
#[test]
fn a_repeated_reading_after_the_pulse_does_not_fire_it_again() {
let mut pump = standard();
pump.observe_progress("install-1", 0.98);
settle(&mut pump);
assert!(pump.observe_progress("install-1", 1.0));
// 推完追值 + 收束(一次)
settle(&mut pump);
assert!(!pump.is_animating(), "前提:脉冲已经走完");
for beat in 0..5 {
assert!(
!pump.observe_progress("install-1", 1.0),
"第 {beat} 条事件不该起轨道(同一条读数)"
);
assert!(!pump.is_animating(), "第 {beat} 条事件不该让泵再转起来");
assert_eq!(
pump.progress_settle("install-1", 1.0),
0.0,
"第 {beat} 条事件不该再闪一次收束"
);
}
// 真变了才动(0.5 是回落,重装/重试)
assert!(pump.observe_progress("install-1", 0.5));
}
/// **`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,307 @@
//! **列表增删轨道**的守卫(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` |
//! | `pump/rows.rs` 的 `visibility_of` 里把「按当前采样」换回 `track.from` | `deleting_a_row_mid_enter_does_not_jump_to_zero`(r1 P2-1) |
//! | `pump/rows.rs` 的 `observe_rows` 里删掉「纯重排当场归位」那一段 | `a_pure_reorder_lands_on_the_new_order_at_once`(r1 P2-3) |
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, "进度不许被重置");
}
/// **进场走到一半就被删的行不许当场消失**(r1 P2-1)。
///
/// 场景:搜索框连打 / 两次分类点选间隔 < 200ms —— 上一次的进场还没走完,这一行的 id 就没了。
/// 拿 `track.from`(进场时是 0)当退场起点会得到「0 → 0」:可见度与高度当拍归零、下面整列上跳,
/// 正是任务①禁的闪跳。反方向(退场中途回来)本来就接当前采样,两个方向必须同一条口径。
///
/// 抓得住:`visibility_of` 用 `track.from` 而不是当前采样。
/// 抓不住:错位那几行的相位(另有 `the_stagger_is_capped_…` 盯着)。
#[test]
fn deleting_a_row_mid_enter_does_not_jump_to_zero() {
let mut pump = standard();
// b 在**第 0 位**:错位档 0,进场立刻开始。
pump.observe_rows(&["a"]);
assert!(pump.observe_rows(&["b", "a"]));
for _ in 0..3 {
pump.advance(FRAME_MS);
}
let mid = visibility(&pump, "b");
assert!(mid > 0.0 && mid < 1.0, "前提:进场走到一半({mid})");
let height_before = pump.row_frame("b").visibility;
// 这一行在进场中途被删掉
assert!(pump.observe_rows(&["a"]));
let after = visibility(&pump, "b");
assert_eq!(
after, height_before,
"退场起点必须是**当前**可见度({height_before}),不许当场归零"
);
assert!(after > 0.0, "这一行还在画、还必须看得见:{after}");
// 之后才慢慢退
pump.advance(FRAME_MS);
assert!(visibility(&pump, "b") < after, "接着往下退");
}
/// **纯重排当场落到新序**(r1 P2-3):同一批 id 换顺序时不建轨道,但画的顺序必须立刻对齐。
///
/// 抓得住:`observe_rows` 少了「没有进/出就把 `order` 对齐 `current`」那一段 —— 那时没有轨道
/// ⇒ `advance` 早退 ⇒ `expire` 不跑 ⇒ `order` 永远停在旧序(`Rows::expire` 是它唯一的归位处)。
#[test]
fn a_pure_reorder_lands_on_the_new_order_at_once() {
let mut pump = standard();
pump.observe_rows(&["a", "b", "c"]);
pump.observe_rows(&["c", "b", "a"]);
assert!(!pump.is_animating(), "纯重排不建轨道");
assert_eq!(
pump.rows_drawn(),
vec!["c", "b", "a"],
"没有轨道就没有 expire,顺序必须当场归位"
);
}
/// 退场中途又回列表:接着当前进度往回走,**不许跳回 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)
}
+11 -2
View File
@@ -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,
+44 -111
View File
@@ -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,
}
}
+10 -34
View File
@@ -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,147 @@
//! 版本行的**行序**与**增删动效**(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(|| {
// **底色是 Canvas,不是 Card**(M305 r1 P2-2):版本行坐在页面的画布上
// (行静息 `fill = None`,脚下那层是 `frame::page` 的 Canvas)。取成 Card 会让
// 遮罩比底色亮一档:浅色 249 vs 247(几乎看不出),深色 tone 6 vs 10(露馅)。
let under = ui.layer(MaterialLayer::Canvas);
iced::Color {
a: (1.0 - visibility).clamp(0.0, 1.0),
..under
}
});
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,252 @@
//! 列表增删动效的**页面侧**判据(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` 的遮罩底色从 `MaterialLayer::Canvas` 改回 `Card` | `the_veil_is_canvas_coloured_and_only_exists_while_moving`(r1 P2-2) |
//! | `rows/list.rs` 里把 `(visibility < 1.0).then(…)` 改成无条件 `Some(…)` | 同一条用例的「静息帧不画」(r1 P2-4) |
//! | `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}`:列表的行序/动效接线被摘掉了"
);
}
}
/// 遮罩的两条口径(r1 P2-2 / P2-4):**底色取 Canvas**、**静息帧不递**。
///
/// 为什么底色必须是 Canvas:版本行坐在页面的画布上(行静息 `fill = None`,脚下是 `frame::page`
/// 的 Canvas)。取成 Card(`SurfaceContainerLow`)会让遮罩比底色亮一档——浅色 249 vs 247 几乎
/// 看不出,深色 tone 6 vs 10 会露馅。
///
/// 为什么静息帧不许递:`veil = Some(..)` 的 α 是「交给渲染器的半透明」,是全仓唯一一处
/// (口径见 `motion/fade.rs`);`visibility >= 1` 时必须一个 quad 都不画,否则静息像素与改造前不同。
#[test]
fn the_veil_is_canvas_coloured_and_only_exists_while_moving() {
let source = include_str!("list.rs");
assert!(
source.contains("MaterialLayer::Canvas"),
"遮罩底色必须是画布层(行脚下那一层)"
);
assert!(
!source.contains("MaterialLayer::Card"),
"遮罩底色取成 Card 会比底色亮一档(深色档露馅)"
);
assert!(
source.contains("(visibility < 1.0).then("),
"静息帧(visibility = 1)不许递遮罩:那是一个交给渲染器的半透明 quad"
);
}
/// `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` 的落点)