From ae336742931645941e94620b896cc1bd5c1b0caa Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 13 Nov 2026 21:42:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(motion):=20M305=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E5=A2=9E=E5=88=A0=EF=BC=88=E9=94=99=E4=BD=8D?= =?UTF-8?q?=E6=B7=A1=E5=85=A5/=E9=80=80=E5=9C=BA=E5=90=8E=E6=89=8D?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=EF=BC=89+=20=E8=BF=9B=E5=BA=A6=E6=8F=92?= =?UTF-8?q?=E5=80=BC=EF=BC=88=E5=88=B0=E7=82=B9=E4=B8=80=E6=AC=A1=E6=94=B6?= =?UTF-8?q?=E6=9D=9F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新轨道两条: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 量行距与墨色) --- .../app-iced/src/components/feedback/mod.rs | 2 +- .../src/components/feedback/progress.rs | 24 +- crates/app-iced/src/components/motion/fade.rs | 34 ++ crates/app-iced/src/components/motion/mod.rs | 14 + .../src/components/motion/progress.rs | 41 ++ .../src/components/motion/pump/mod.rs | 18 + .../src/components/motion/pump/progress.rs | 242 ++++++++++++ .../src/components/motion/pump/rows.rs | 353 ++++++++++++++++++ .../app-iced/src/components/motion/reveal.rs | 244 ++++++++++++ crates/app-iced/src/components/motion/rows.rs | 60 +++ .../src/components/motion/tests/mod.rs | 2 + .../src/components/motion/tests/progress.rs | 179 +++++++++ .../src/components/motion/tests/rows.rs | 254 +++++++++++++ .../src/components/motion/transition.rs | 64 ++++ crates/app-iced/src/pages/download/demo.rs | 12 + .../src/pages/download/demo/motion.rs | 67 ++++ crates/app-iced/src/pages/download/install.rs | 13 +- crates/app-iced/src/pages/download/model.rs | 155 +++----- .../src/pages/download/model/install.rs | 118 ++++++ crates/app-iced/src/pages/download/rows.rs | 44 +-- .../app-iced/src/pages/download/rows/list.rs | 144 +++++++ .../src/pages/download/rows/motion_tests.rs | 225 +++++++++++ .../app-iced/src/pages/download/rows/tests.rs | 13 +- .../app-iced/src/pages/download/tests/i18n.rs | 6 +- 24 files changed, 2175 insertions(+), 153 deletions(-) create mode 100644 crates/app-iced/src/components/motion/fade.rs create mode 100644 crates/app-iced/src/components/motion/progress.rs create mode 100644 crates/app-iced/src/components/motion/pump/progress.rs create mode 100644 crates/app-iced/src/components/motion/pump/rows.rs create mode 100644 crates/app-iced/src/components/motion/reveal.rs create mode 100644 crates/app-iced/src/components/motion/rows.rs create mode 100644 crates/app-iced/src/components/motion/tests/progress.rs create mode 100644 crates/app-iced/src/components/motion/tests/rows.rs create mode 100644 crates/app-iced/src/pages/download/demo/motion.rs create mode 100644 crates/app-iced/src/pages/download/model/install.rs create mode 100644 crates/app-iced/src/pages/download/rows/list.rs create mode 100644 crates/app-iced/src/pages/download/rows/motion_tests.rs diff --git a/crates/app-iced/src/components/feedback/mod.rs b/crates/app-iced/src/components/feedback/mod.rs index e53f62c..5d59eaf 100644 --- a/crates/app-iced/src/components/feedback/mod.rs +++ b/crates/app-iced/src/components/feedback/mod.rs @@ -87,5 +87,5 @@ mod tests; pub use dialog::{dialog, scrimmed, Dialog}; 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}; diff --git a/crates/app-iced/src/components/feedback/progress.rs b/crates/app-iced/src/components/feedback/progress.rs index fbf5b57..fbae58a 100644 --- a/crates/app-iced/src/components/feedback/progress.rs +++ b/crates/app-iced/src/components/feedback/progress.rs @@ -193,8 +193,30 @@ pub(crate) fn circular_colors(ui: &Ui) -> (iced::Color, iced::Color) { /// 轨道宽 `Fill`(由宿主决定),指示条按占比分到 `FillPortion` 上——**不需要知道轨道像素宽**, /// 所以 50% 在任何宿主宽度下都精确是半条。 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 (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![] .width(Length::Fill) diff --git a/crates/app-iced/src/components/motion/fade.rs b/crates/app-iced/src/components/motion/fade.rs new file mode 100644 index 0000000..f3e8310 --- /dev/null +++ b/crates/app-iced/src/components/motion/fade.rs @@ -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)) +} diff --git a/crates/app-iced/src/components/motion/mod.rs b/crates/app-iced/src/components/motion/mod.rs index 431153e..6dddf1f 100644 --- a/crates/app-iced/src/components/motion/mod.rs +++ b/crates/app-iced/src/components/motion/mod.rs @@ -49,7 +49,11 @@ //! 模态的**缩放**走 `renderer.with_transformation`(两个后端都实现,见 [`scaled`])。 mod clip; +mod fade; +pub mod progress; mod pump; +mod reveal; +pub mod rows; mod transition; mod zoom; @@ -60,12 +64,16 @@ pub use pump::{Pump, FRAME_MS, MAX_STEP_MS}; // 规格表与采样形状全部经这里导出:页面只拿得到「这一帧怎么画」,拿不到「用哪一档、多久」。 // (`page_spec`/`Leg`/`*Spec` 只被测试与文档引用,产品路径只用 `Kind` 与采样形状。) pub use clip::clipped; +pub use fade::mix; +pub use reveal::revealed; #[allow(unused_imports)] pub use transition::{ enter_offset, exit_offset, modal_scale, modal_spec, page_spec, rail_spec, Kind, Leg, ModalFrame, ModalSpec, PageFrame, PageSpec, RailSpec, MODAL_SCALE_FROM, }; pub use zoom::scaled; +// M305:列表增删 + 进度插值。页面只拿得到「这一帧怎么画」,轨道与时长都在泵里 +// (两个入口都是 `pub mod`:`motion::rows::observe/frame`、`motion::progress::observe/value`)。 use std::cell::{Cell, RefCell}; 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() } +/// 起跑之后把帧基准重置到「现在」(与 `observe` / `observe_modal` 同一个动作,抽出来给 M305 的 +/// 两条轨道共用:空闲期没有帧,不重置的话第一帧会按整段空闲时长结算——`MAX_STEP_MS` 是第二道兜底)。 +fn reset_frame_clock() { + LAST_FRAME.with(|last| last.set(Some(Instant::now()))); +} + /// 页主体的内容**标识**:**每条消息处理完**调一次(`App::sync_motion`),标识变了就此起跑。 /// /// `screen` 是 `pages::Screen` 的稳定键(`None` = 这一屏不归 pages 画:账户页 / AI 独立页), diff --git a/crates/app-iced/src/components/motion/progress.rs b/crates/app-iced/src/components/motion/progress.rs new file mode 100644 index 0000000..abc1ce7 --- /dev/null +++ b/crates/app-iced/src/components/motion/progress.rs @@ -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), + } + }) +} diff --git a/crates/app-iced/src/components/motion/pump/mod.rs b/crates/app-iced/src/components/motion/pump/mod.rs index ba6d09a..bedfb5e 100644 --- a/crates/app-iced/src/components/motion/pump/mod.rs +++ b/crates/app-iced/src/components/motion/pump/mod.rs @@ -18,13 +18,17 @@ use unlucky_design::motion::{DurationToken, MotionScale}; mod modal; +mod progress; mod rail; +mod rows; use super::transition::{ enter_offset, exit_offset, modal_spec, page_spec, Kind, PageFrame, PageSpec, }; pub(crate) use modal::Modal; +pub(crate) use progress::{Progress, ProgressFrame}; pub(crate) use rail::RailTrack; +pub(crate) use rows::{RowFrame, Rows}; /// 逐帧**推进时间轴**的最短间隔(16 ms ≈ 60 次/秒)。 /// @@ -73,6 +77,10 @@ pub struct Pump { nav: Option, /// 左轨的目标档(`true` = 展开 88)。出厂展开;启动时按落盘档由 [`Pump::snap_nav`] 对表。 nav_open: bool, + /// 列表行增删的轨道(M305):两条清单 + 在跑的可见度轨道,见 `pump/rows.rs`。 + rows: Rows, + /// 进度插值 + 收束脉冲(M305),见 `pump/progress.rs`。 + progress: Progress, /// 退场走完了、等调用方真正关掉那个框([`Pump::take_modal_closed`] 取走一次)。 close_pending: bool, screen: Option, @@ -97,6 +105,8 @@ impl Pump { rail_open: false, nav: None, nav_open: true, + rows: Rows::new(), + progress: Progress::new(), close_pending: false, screen: None, rank: None, @@ -127,6 +137,9 @@ impl Pump { // `app/update/motion_sync.rs` 在定档之后 `snap_nav` 对表(那里的判据是「没有轨在跑」)。 self.nav = None; self.nav_open = true; + // M305:定档即复位两条新轨道。行清单**不**在这里猜(第一次观察只对表,见 `rows.rs`)。 + self.reset_rows(); + self.reset_progress(); self.close_pending = false; if scale.is_off() { // 关掉动效 = 直接落终态:把在跑的轨道清掉,而不是让它跑一遍 0 时长。 @@ -155,6 +168,8 @@ impl Pump { self.page.is_some() || self.rail.is_some() || self.nav.is_some() + || self.rows.is_animating() + || self.progress.is_animating() || matches!(self.modal, Modal::Opening { .. } | Modal::Closing { .. }) } @@ -327,6 +342,9 @@ impl Pump { self.nav = None; } } + // M305:行与进度各自清理(各自的「到期即删」出口,见两个子模块)。 + self.expire_rows(now); + self.expire_progress(now); let spec = modal_spec(); match self.modal { Modal::Opening { start_ms } if now - start_ms >= self.leg_ms(spec.enter.duration) => { diff --git a/crates/app-iced/src/components/motion/pump/progress.rs b/crates/app-iced/src/components/motion/pump/progress.rs new file mode 100644 index 0000000..694a8dc --- /dev/null +++ b/crates/app-iced/src/components/motion/pump/progress.rs @@ -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, + track: Option, + settle: Option, + /// **上一次报上来的值**(没有轨道时「这一帧画多少」的答案)。 + /// + /// 为什么必须记它:第一次报一个 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 { + 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(); + } +} diff --git a/crates/app-iced/src/components/motion/pump/rows.rs b/crates/app-iced/src/components/motion/pump/rows.rs new file mode 100644 index 0000000..f43f177 --- /dev/null +++ b/crates/app-iced/src/components/motion/pump/rows.rs @@ -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, + /// 数据模型现在给的行(真值)。 + current: Vec, + /// 在跑的轨道(进 + 出)。`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)) + } +} diff --git a/crates/app-iced/src/components/motion/reveal.rs b/crates/app-iced/src/components/motion/reveal.rs new file mode 100644 index 0000000..baf556e --- /dev/null +++ b/crates/app-iced/src/components/motion/reveal.rs @@ -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>, + visible_height: f32, + content_height: f32, + offset_y: f32, + veil: Option, +) -> 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, +} + +impl Widget + 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 { + self.content.as_widget().children() + } + + fn diff(&self, tree: &mut Tree) { + self.content.as_widget().diff(tree); + } + + fn size(&self) -> Size { + self.content.as_widget().size() + } + + fn size_hint(&self) -> Size { + 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> { + let child = layout.children().next()?; + self.content + .as_widget_mut() + .overlay(tree, child, renderer, viewport, translation) + } +} diff --git a/crates/app-iced/src/components/motion/rows.rs b/crates/app-iced/src/components/motion/rows.rs new file mode 100644 index 0000000..e40e95b --- /dev/null +++ b/crates/app-iced/src/components/motion/rows.rs @@ -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 { + 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()) +} diff --git a/crates/app-iced/src/components/motion/tests/mod.rs b/crates/app-iced/src/components/motion/tests/mod.rs index f49bad9..1b47046 100644 --- a/crates/app-iced/src/components/motion/tests/mod.rs +++ b/crates/app-iced/src/components/motion/tests/mod.rs @@ -10,7 +10,9 @@ mod modal; mod nav; +mod progress; mod rail; +mod rows; mod timeline; use super::pump::Pump; diff --git a/crates/app-iced/src/components/motion/tests/progress.rs b/crates/app-iced/src/components/motion/tests/progress.rs new file mode 100644 index 0000000..6f1925d --- /dev/null +++ b/crates/app-iced/src/components/motion/tests/progress.rs @@ -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)" + ); +} diff --git a/crates/app-iced/src/components/motion/tests/rows.rs b/crates/app-iced/src/components/motion/tests/rows.rs new file mode 100644 index 0000000..53091dd --- /dev/null +++ b/crates/app-iced/src/components/motion/tests/rows.rs @@ -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 { + 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 = Vec::new(); + let ids: Vec = (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})"); +} diff --git a/crates/app-iced/src/components/motion/transition.rs b/crates/app-iced/src/components/motion/transition.rs index 07df578..4a868b8 100644 --- a/crates/app-iced/src/components/motion/transition.rs +++ b/crates/app-iced/src/components/motion/transition.rs @@ -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` 每帧取一次,不起轨道)。 #[derive(Debug, Clone, Copy, PartialEq)] pub struct PageFrame { diff --git a/crates/app-iced/src/pages/download/demo.rs b/crates/app-iced/src/pages/download/demo.rs index 79e827f..04c514e 100644 --- a/crates/app-iced/src/pages/download/demo.rs +++ b/crates/app-iced/src/pages/download/demo.rs @@ -46,6 +46,10 @@ use unlucky_presenter::state::AppState; use unlucky_i18n::Catalog; +mod motion; + +use motion::list_motion; + use super::model::{Category, Download, InstallView, VersionRow}; use super::options::{InstallDraft, Loader}; @@ -113,6 +117,12 @@ pub fn demo(catalog: Catalog) -> Download { // 复审 P2-2 的取样点:**22 个主版本桶**(MOJANG 清单里 `Release` 就是这个量级)。 // chips 档一行放不下 ⇒ 主版本行必须能横向滚,否则后面的桶永远点不到。 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 { install: Some(install(InstallStage::Downloading, 0.42, catalog)), ..Download::loading() @@ -145,6 +155,8 @@ fn ready(catalog: Catalog) -> Download { query: String::new(), selected_major: None, type_rail_folded: false, + // 演示模型没有「还在退场」的行:动效只在快照重建时由泵产生(M305)。 + leaving: Vec::new(), } } diff --git a/crates/app-iced/src/pages/download/demo/motion.rs b/crates/app-iced/src/pages/download/demo/motion.rs new file mode 100644 index 0000000..6b28f54 --- /dev/null +++ b/crates/app-iced/src/pages/download/demo/motion.rs @@ -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::().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) +} diff --git a/crates/app-iced/src/pages/download/install.rs b/crates/app-iced/src/pages/download/install.rs index f91a561..4f71c6b 100644 --- a/crates/app-iced/src/pages/download/install.rs +++ b/crates/app-iced/src/pages/download/install.rs @@ -75,7 +75,8 @@ use unlucky_presenter::router::Route; use unlucky_i18n::{Catalog, Key}; 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 super::super::frame; @@ -209,7 +210,15 @@ fn installing<'a>(ui: &'a Ui, catalog: Catalog, install: &'a InstallView) -> Ele let mut body = column![head]; // 进度已知才画条:未知时画 indeterminate 会得到一个静止的假进度(见模块文档)。 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( ui, diff --git a/crates/app-iced/src/pages/download/model.rs b/crates/app-iced/src/pages/download/model.rs index b2756f2..68825ec 100644 --- a/crates/app-iced/src/pages/download/model.rs +++ b/crates/app-iced/src/pages/download/model.rs @@ -23,13 +23,17 @@ //! 的规矩 2:core 的中文串只给日志和 CLI)。所以这里只搬**结构化字段**(阶段枚举、计数、字节), //! 文案按 `Key` 现求——中文环境自然出中文,英文环境不需要任何中文字体。 -use unlucky_presenter::dto::{ - human_bytes, InstallProgressView, InstallStage, VersionKind, VersionSummary, -}; +use unlucky_presenter::dto::{VersionKind, VersionSummary}; use unlucky_presenter::state::AppState; use unlucky_i18n::{Catalog, Key, Lang}; +mod install; + +pub use install::InstallView; + +use crate::app::components::motion; + use super::options::{InstallDraft, OptionsView}; /// 取消安装之后那一句提示(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, - /// 整体进度 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)] pub struct Download { @@ -226,6 +122,12 @@ pub struct Download { pub type_rail_folded: bool, /// 主版本桶(第二级侧栏的数据源;由 [`Self::rows`] 聚合,所以只含**当前分类**的桶) pub buckets: Vec, + /// **还在退场的行**(M305):已经不在这份模型的 `rows` 里,但淡出还没走完。 + /// + /// 为什么数据要留在这里:`motion::rows::drawn` 会吐出这些 id(删除项淡出后才真正移除), + /// 而它们的 `VersionRow` 已经从过滤后的 `rows` 里消失了——不留一份,那一路只能画空壳。 + /// 池子由 [`Self::from_snapshot`] 在**同一次** update 里从快照重建(见那里的注释)。 + pub leaving: Vec, } 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 { // 桶由**过滤后的行**聚合:第二级侧栏只列当前分类里真实出现的版本族。 buckets: super::group::buckets(&rows), @@ -325,6 +257,7 @@ impl Download { selected_major: None, // 折叠也是纯视图态;搜索重建时由 `rebuild_after_query` 前后各搬一次。 type_rail_folded: false, + leaving, } } diff --git a/crates/app-iced/src/pages/download/model/install.rs b/crates/app-iced/src/pages/download/model/install.rs new file mode 100644 index 0000000..54f6217 --- /dev/null +++ b/crates/app-iced/src/pages/download/model/install.rs @@ -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, + /// 整体进度 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, + } +} diff --git a/crates/app-iced/src/pages/download/rows.rs b/crates/app-iced/src/pages/download/rows.rs index d414a48..23921d6 100644 --- a/crates/app-iced/src/pages/download/rows.rs +++ b/crates/app-iced/src/pages/download/rows.rs @@ -63,7 +63,7 @@ //! 本页的行是 44 的单行档,同样不加行距;[`ROW_GAP`] 只用在**行内**那三个格子的间距上。 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::icons::IconKind; @@ -85,8 +85,16 @@ use super::install::{self, panel_state, PanelState}; use super::model::{Download, VersionRow}; use super::states; +mod list; 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}; /// 列表列(三栏档与 chips 档共用一份)。 @@ -118,7 +126,7 @@ pub(super) fn content<'a>( } else { list_height }; - list(ui, catalog, model, &visible, height) + list::list(ui, catalog, model, &visible, height) }; // 列表与尾部面板之间**总是**有一条分隔线(三态一致):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, -) -> Element<'a, Message> { - let entries = column( - visible - .iter() - .map(|entry| version_row(ui, catalog, model, entry)) - .collect::>(), - ) - // 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`(版本号,可点选)+ 类型 + 日期 + 安装图标按钮(**四样同一行**)。 /// /// 点版本号 = [`Message::SelectVersion`](页面内选中,不 dispatch);点尾随按钮 = diff --git a/crates/app-iced/src/pages/download/rows/list.rs b/crates/app-iced/src/pages/download/rows/list.rs new file mode 100644 index 0000000..605b427 --- /dev/null +++ b/crates/app-iced/src/pages/download/rows/list.rs @@ -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, +) -> 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 = 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() +} diff --git a/crates/app-iced/src/pages/download/rows/motion_tests.rs b/crates/app-iced/src/pages/download/rows/motion_tests.rs new file mode 100644 index 0000000..5ff4447 --- /dev/null +++ b/crates/app-iced/src/pages/download/rows/motion_tests.rs @@ -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(); +} diff --git a/crates/app-iced/src/pages/download/rows/tests.rs b/crates/app-iced/src/pages/download/rows/tests.rs index e05ab31..ccbbd33 100644 --- a/crates/app-iced/src/pages/download/rows/tests.rs +++ b/crates/app-iced/src/pages/download/rows/tests.rs @@ -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!( label_room >= 176.0, "版本号那一格只剩 {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() { let source = include_str!("../rows.rs"); assert!(source.contains("search_bar("), "搜索条没装"); - assert!(source.contains("Message::SetVersionQuery"), "搜索条不产生查询消息"); - assert!(source.contains("Message::ClearVersionQuery"), "搜索条没有清空入口"); + assert!( + source.contains("Message::SetVersionQuery"), + "搜索条不产生查询消息" + ); + assert!( + source.contains("Message::ClearVersionQuery"), + "搜索条没有清空入口" + ); } diff --git a/crates/app-iced/src/pages/download/tests/i18n.rs b/crates/app-iced/src/pages/download/tests/i18n.rs index 348af6c..9d461f0 100644 --- a/crates/app-iced/src/pages/download/tests/i18n.rs +++ b/crates/app-iced/src/pages/download/tests/i18n.rs @@ -17,12 +17,16 @@ use super::*; /// 它只看字面出现,用宏拼 key 名能骗过它。 #[test] fn every_download_key_is_referenced_by_the_page() { - const SOURCES: [&str; 12] = [ + const SOURCES: [&str; 14] = [ include_str!("../../download.rs"), include_str!("../model.rs"), // M107:分类枚举搬进 `model/category.rs`(`Category::label_key` 在那儿,漏扫会误判死 key) include_str!("../model/category.rs"), + // M305:安装面板的展示模型搬进 `model/install.rs`(`stage_key` 在那儿) + include_str!("../model/install.rs"), include_str!("../rows.rs"), + // M305:行序与增删动效(`rows/list.rs`;后续若在那儿落 key 不会漏) + include_str!("../rows/list.rs"), // M107:两级侧栏与分组规则(`VersionRailTitle` / `VersionMajorAll` 的落点) include_str!("../rails.rs"), // M125:主版本栏搬进 `rails/major.rs`(`VersionMajorTitle` / `VersionMajorAll` 的落点)