diff --git a/crates/app-iced/src/components/motion/fade.rs b/crates/app-iced/src/components/motion/fade.rs index f3e8310..57add7d 100644 --- a/crates/app-iced/src/components/motion/fade.rs +++ b/crates/app-iced/src/components/motion/fade.rs @@ -2,23 +2,35 @@ //! //! ## 这条纪律是什么(D27) //! -//! 「CPU 档零半透明」:整窗重绘里每多一层半透明,软件渲染就多一遍混合。仓内唯一的 α 出口是 +//! 「CPU 档零半透明」:整窗重绘里每多一层半透明,软件渲染就多一遍混合。仓内 α 的正统出口是 //! **令牌层预合成**(`c.with_alpha(α).over(底)`,见 `interaction::disabled` 与 //! `dialog/view.rs::backdrop_color_at`):产物**恒不透明**。 //! `interaction/mod.rs` 的用例把这条钉成硬线(「为了淡出改用 `Color { a: 0.5 }`」直接红)。 //! -//! 本模块只剩一件工具:[`mix`]——两个**不透明**色之间按进度插值,用于「进度条到达 100% 时那一下 +//! 本模块的工具是 [`mix`]——两个**不透明**色之间按进度插值,用于「进度条到达 100% 时那一下 //! 强调收束」(`feedback/progress.rs::linear_with`)。它不改几何、不改厚度,所以**不引起重排**。 //! -//! ## 为什么这里没有「整行淡出」 +//! ## 唯一的例外:子树淡出(口径写在这里,别处不许再有第二种说法) //! -//! 列表行的淡出走的是 `motion/reveal.rs` 的收尾遮罩(原因写在那里:`Element<'a>` 借 `&'a Ui`, -//! 视图函数里现造的「淡化版 Ui」撑不到返回;把它泄漏成 `'static` 会让每帧每行泄漏一份 49 色方案)。 -//! 行内容因此**永远逐像素实色绘制**,只有裁切框与那一层遮罩在变。 +//! | 场景 | 做法 | 产物透明度 | +//! |---|---|---| +//! | 颜色插值(进度条收束、遮罩浓度、禁用层) | [`mix`] / `with_alpha(α).over(底)` | **恒 1.0** | +//! | **一整棵子树淡出**(列表行) | `motion/reveal.rs` 的收尾遮罩:与**画布同色**的 quad,α = 1 − 可见度,且**必须自成一层** | 动画期间 < 1,**静息帧严格不画** | +//! +//! 为什么子树淡出做不到「预合成」:预合成要求先知道被盖住的像素(`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` 的插值(预合成口径,同 [`faded`] 的纪律)。 +/// 两个**不透明**颜色之间按 `t` 的插值。 /// /// 用在「进度条收束」这类没有背景色可叠的地方:`to.with_alpha(t).over(from)` 得到的是 /// 「把 `to` 按 t 盖在 `from` 上」的**实色**,产物恒不透明(D27);端点 `t = 0` / `1` diff --git a/crates/app-iced/src/components/motion/pump/progress.rs b/crates/app-iced/src/components/motion/pump/progress.rs index 694a8dc..5bdcb6c 100644 --- a/crates/app-iced/src/components/motion/pump/progress.rs +++ b/crates/app-iced/src/components/motion/pump/progress.rs @@ -162,6 +162,14 @@ impl Pump { 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 diff --git a/crates/app-iced/src/components/motion/pump/rows.rs b/crates/app-iced/src/components/motion/pump/rows.rs index f43f177..b37c879 100644 --- a/crates/app-iced/src/components/motion/pump/rows.rs +++ b/crates/app-iced/src/components/motion/pump/rows.rs @@ -137,12 +137,16 @@ impl Rows { .any(|(current, id)| current != id) } - /// 现在的可见度(没有轨道 = 1.0)。 - fn visibility_of(&self, id: &str) -> f32 { + /// **这一帧**的可见度(没有轨道 = 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.from) + .map_or(1.0, |(_, track)| track.sample(now_ms - track.start_ms)) } /// 换一条轨道(同 id 覆盖:中途反向就接着当前进度走)。 @@ -270,6 +274,7 @@ impl Pump { }; 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 @@ -280,8 +285,9 @@ impl Pump { .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); + let from = self.rows.visibility_of(&id, now); self.rows .retarget(&id, from, 0.0, spec.exit, exit_ms, stagger, index); } @@ -300,7 +306,7 @@ impl Pump { (!known || retracting).then(|| ((*id).to_owned(), index)) }) .collect(); - let now = self.elapsed_ms(); + let arrived = arriving.len(); for (id, index) in arriving { // 退场中途回来(还有轨道):接着**当前**可见度走,不跳回 0;全新的行从 0 起。 let from = match self.rows.tracks.iter().find(|(tracked, _)| tracked == &id) { @@ -312,7 +318,14 @@ impl Pump { .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() } diff --git a/crates/app-iced/src/components/motion/reveal.rs b/crates/app-iced/src/components/motion/reveal.rs index baf556e..2e5c806 100644 --- a/crates/app-iced/src/components/motion/reveal.rs +++ b/crates/app-iced/src/components/motion/reveal.rs @@ -14,11 +14,20 @@ //! | 子内容的**排版** | `content_height`(整段过渡里不变 ⇒ 不重排) | //! | 自己占的**格子**(= 裁切框) | `visible_height`(逐帧变,由行的可见度给) | //! -//! ## 绘制 +//! ## 绘制:裁切 + **一层与画布同色的收尾遮罩** //! -//! 走 `Renderer::with_layer(bounds)`(tiny-skia 是 `push_clip`,wgpu 是同族的层栈),超出的部分 -//! **画不出来**——「收起到 0」在像素上就是「一格都不占」,不需要半透明(D27 的单一口径)。 -//! 淡出是另一件事,在 `motion/fade.rs`(色板预合成)。 +//! 裁切走 `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 之前同源) | +//! | 口径归属 | 这是全仓**唯一**一处「把 α 交给渲染器」的地方,规则的真源写在 `motion/fade.rs` 的模块文档。`interaction/mod.rs` 的 α 守卫只扫状态层取色、**扫不到**这里(不经过任何 `Style`)——盲区已登记在 `fade.rs` | //! //! ## 与 `clip.rs` 的区别(为什么不再写一个通用件) //! diff --git a/crates/app-iced/src/components/motion/tests/progress.rs b/crates/app-iced/src/components/motion/tests/progress.rs index 6f1925d..2eda2db 100644 --- a/crates/app-iced/src/components/motion/tests/progress.rs +++ b/crates/app-iced/src/components/motion/tests/progress.rs @@ -7,6 +7,7 @@ //! | `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` | @@ -122,6 +123,41 @@ fn the_settle_fires_once_and_is_zero_at_both_ends() { 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() { diff --git a/crates/app-iced/src/components/motion/tests/rows.rs b/crates/app-iced/src/components/motion/tests/rows.rs index 53091dd..36c3691 100644 --- a/crates/app-iced/src/components/motion/tests/rows.rs +++ b/crates/app-iced/src/components/motion/tests/rows.rs @@ -12,6 +12,8 @@ //! | `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; @@ -233,6 +235,57 @@ fn observing_the_same_list_twice_is_a_no_op() { 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() { diff --git a/crates/app-iced/src/pages/download/rows/list.rs b/crates/app-iced/src/pages/download/rows/list.rs index 605b427..d5b6725 100644 --- a/crates/app-iced/src/pages/download/rows/list.rs +++ b/crates/app-iced/src/pages/download/rows/list.rs @@ -122,10 +122,13 @@ pub(super) fn rows_column<'a>( // 于是末帧与静息逐像素相同。为什么不是「把 α 交给行内容」、为什么遮罩必须自成一层, // 见 `motion/reveal.rs` 的模块文档(两件事都有实测记录)。 let veil = (visibility < 1.0).then(|| { - let card = ui.layer(MaterialLayer::Card); + // **底色是 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), - ..card + ..under } }); entries = entries.push( diff --git a/crates/app-iced/src/pages/download/rows/motion_tests.rs b/crates/app-iced/src/pages/download/rows/motion_tests.rs index 5ff4447..a79d17e 100644 --- a/crates/app-iced/src/pages/download/rows/motion_tests.rs +++ b/crates/app-iced/src/pages/download/rows/motion_tests.rs @@ -10,6 +10,8 @@ //! | `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; @@ -196,6 +198,31 @@ fn the_list_is_fed_by_the_pump_order() { } } +/// 遮罩的两条口径(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() {