fix(motion): M305 r1 —— P1 空轨道重复脉冲 + 三条 P2(进场被删跳零 / 纯重排不落终态 / 遮罩取色与口径)

- P1-1:observe_progress 在「没有轨道且目标没变」时直接 return false。少了这一条,
  ratio 恒为 1.0 之后每条 stage 事件都会插一条 from==to 的空轨道 ⇒ 200ms 后再闪一次
  150ms 脉冲(违反「一次收束不循环」+ 空烧帧)。用例 a_repeated_reading_after_the_pulse…
- P2-1:visibility_of 改成按**当前采样**取(原来取 track.from ⇒ 进场途中被删的行 0→0,
  高度当拍归零、下面整列上跳)。用例 deleting_a_row_mid_enter_does_not_jump_to_zero
- P2-2:遮罩底色 Card → Canvas(版本行脚下就是画布;深色档会露出亮一档)
- P2-3:纯重排(同 id 换序)当场把 order 对齐 current。没有轨道 ⇒ advance 早退 ⇒
  expire 不跑 ⇒ order 会永远停在旧序。用例 a_pure_reorder_lands_on_the_new_order_at_once
- P2-4:fade.rs 收成唯一口径(预合成 = 恒不透明;子树淡出是唯一例外,且必须自成一层),
  reveal.rs 指向它并登记 interaction α 守卫扫不到这一处的盲区
- 出图补深色档 list-exit-dark.png,probe 复跑 PASS(墨色 110→148 / 105→154)
This commit is contained in:
AskaEth committed 2026-11-13 22:12:43 +08:00
1 parent ae33674293
commit d24ef21bee
8 files changed
+179 -18

No files matched your search

+19 -7
View File
@@ -2,23 +2,35 @@
//! //!
//! ## 这条纪律是什么(D27) //! ## 这条纪律是什么(D27)
//! //!
//! 「CPU 档零半透明」:整窗重绘里每多一层半透明,软件渲染就多一遍混合。仓内唯一的 α 出口是 //! 「CPU 档零半透明」:整窗重绘里每多一层半透明,软件渲染就多一遍混合。仓内 α 的正统出口是
//! **令牌层预合成**(`c.with_alpha(α).over(底)`,见 `interaction::disabled` 与 //! **令牌层预合成**(`c.with_alpha(α).over(底)`,见 `interaction::disabled` 与
//! `dialog/view.rs::backdrop_color_at`):产物**恒不透明**。 //! `dialog/view.rs::backdrop_color_at`):产物**恒不透明**。
//! `interaction/mod.rs` 的用例把这条钉成硬线(「为了淡出改用 `Color { a: 0.5 }`」直接红)。 //! `interaction/mod.rs` 的用例把这条钉成硬线(「为了淡出改用 `Color { a: 0.5 }`」直接红)。
//! //!
//! 本模块只剩一件工具:[`mix`]——两个**不透明**色之间按进度插值,用于「进度条到达 100% 时那一下 //! 本模块的工具是 [`mix`]——两个**不透明**色之间按进度插值,用于「进度条到达 100% 时那一下
//! 强调收束」(`feedback/progress.rs::linear_with`)。它不改几何、不改厚度,所以**不引起重排**。 //! 强调收束」(`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; use unlucky_design::color::Color;
/// 两个**不透明**颜色之间按 `t` 的插值(预合成口径,同 [`faded`] 的纪律)。 /// 两个**不透明**颜色之间按 `t` 的插值。
/// ///
/// 用在「进度条收束」这类没有背景色可叠的地方:`to.with_alpha(t).over(from)` 得到的是 /// 用在「进度条收束」这类没有背景色可叠的地方:`to.with_alpha(t).over(from)` 得到的是
/// 「把 `to` 按 t 盖在 `from` 上」的**实色**,产物恒不透明(D27);端点 `t = 0` / `1` /// 「把 `to` 按 t 盖在 `from` 上」的**实色**,产物恒不透明(D27);端点 `t = 0` / `1`
@@ -162,6 +162,14 @@ impl Pump {
self.progress.shown = target; self.progress.shown = target;
return false; 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 if self
.progress .progress
.track .track
@@ -137,12 +137,16 @@ impl Rows {
.any(|(current, id)| current != id) .any(|(current, id)| current != id)
} }
/// 现在的可见度(没有轨道 = 1.0)。 /// **这一帧**的可见度(没有轨道 = 1.0)。
fn visibility_of(&self, id: &str) -> f32 { ///
/// 必须取**当前采样**而不是 `track.from`:进场走到一半就被删的行,`from` 是 0(它从不可见
/// 开始),拿它当退场起点会得到「0 → 0」——可见度与高度当拍归零、下面整列上跳,正是任务①
/// 禁的闪跳。退场中途回来的那一侧本来就走采样,两个方向必须同一条口径(M305 r1 P2-1)。
fn visibility_of(&self, id: &str, now_ms: f64) -> f32 {
self.tracks self.tracks
.iter() .iter()
.find(|(tracked, _)| tracked == id) .find(|(tracked, _)| tracked == id)
.map_or(1.0, |(_, track)| track.from) .map_or(1.0, |(_, track)| track.sample(now_ms - track.start_ms))
} }
/// 换一条轨道(同 id 覆盖:中途反向就接着当前进度走)。 /// 换一条轨道(同 id 覆盖:中途反向就接着当前进度走)。
@@ -270,6 +274,7 @@ impl Pump {
}; };
let enter_ms = f64::from(scale.ms(spec.enter.duration)); let enter_ms = f64::from(scale.ms(spec.enter.duration));
let exit_ms = f64::from(scale.ms(spec.exit.duration)); let exit_ms = f64::from(scale.ms(spec.exit.duration));
let now = self.elapsed_ms();
// ① 离开的:用**旧顺序**里的位置算错位(用户看到的原本次序)。 // ① 离开的:用**旧顺序**里的位置算错位(用户看到的原本次序)。
let leaving: Vec<(String, usize)> = self let leaving: Vec<(String, usize)> = self
@@ -280,8 +285,9 @@ impl Pump {
.filter(|(_, id)| !ids.contains(&id.as_str())) .filter(|(_, id)| !ids.contains(&id.as_str()))
.map(|(index, id)| (id.clone(), index)) .map(|(index, id)| (id.clone(), index))
.collect(); .collect();
let left = leaving.len();
for (id, index) in leaving { for (id, index) in leaving {
let from = self.rows.visibility_of(&id); let from = self.rows.visibility_of(&id, now);
self.rows self.rows
.retarget(&id, from, 0.0, spec.exit, exit_ms, stagger, index); .retarget(&id, from, 0.0, spec.exit, exit_ms, stagger, index);
} }
@@ -300,7 +306,7 @@ impl Pump {
(!known || retracting).then(|| ((*id).to_owned(), index)) (!known || retracting).then(|| ((*id).to_owned(), index))
}) })
.collect(); .collect();
let now = self.elapsed_ms(); let arrived = arriving.len();
for (id, index) in arriving { for (id, index) in arriving {
// 退场中途回来(还有轨道):接着**当前**可见度走,不跳回 0;全新的行从 0 起。 // 退场中途回来(还有轨道):接着**当前**可见度走,不跳回 0;全新的行从 0 起。
let from = match self.rows.tracks.iter().find(|(tracked, _)| tracked == &id) { 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); .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(); 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() self.rows.is_animating()
} }
@@ -14,11 +14,20 @@
//! | 子内容的**排版** | `content_height`(整段过渡里不变 ⇒ 不重排) | //! | 子内容的**排版** | `content_height`(整段过渡里不变 ⇒ 不重排) |
//! | 自己占的**格子**(= 裁切框) | `visible_height`(逐帧变,由行的可见度给) | //! | 自己占的**格子**(= 裁切框) | `visible_height`(逐帧变,由行的可见度给) |
//! //!
//! ## 绘制 //! ## 绘制:裁切 + **一层与画布同色的收尾遮罩**
//! //!
//! 走 `Renderer::with_layer(bounds)`(tiny-skia 是 `push_clip`,wgpu 是同族的层栈),超出的部分 //! 裁切走 `Renderer::with_layer(bounds)`(tiny-skia 是 `push_clip`,wgpu 是同族的层栈):超出的
//! **画不出来**——「收起到 0」在像素上就是「一格都不占」,不需要半透明(D27 的单一口径)。 //! 部分画不出来——「收起到 0」在像素上就是「一格都不占」。
//! 淡出是另一件事,在 `motion/fade.rs`(色板预合成)。 //!
//! 淡出走 [`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` 的区别(为什么不再写一个通用件) //! ## 与 `clip.rs` 的区别(为什么不再写一个通用件)
//! //!
@@ -7,6 +7,7 @@
//! | `pump/progress.rs` 的 `Progress::value` 里把插值那一行换成 `Some(track.to)`(**改回硬跳**) | `the_value_chases_the_target_instead_of_jumping` 的「没有一帧落在两端之间」(实测见报告) | //! | `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` 的 `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` 的 `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` 的 `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` | //! | `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; 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` 严格等于目标**、零采样(任务里那条「值仍是最终值」的断言版)。 /// **`MotionScale::NONE` 严格等于目标**、零采样(任务里那条「值仍是最终值」的断言版)。
#[test] #[test]
fn no_scale_renders_the_target_itself() { fn no_scale_renders_the_target_itself() {
@@ -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` 的 `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` 的 `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` 的 `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::Pump;
use super::super::pump::RowFrame; 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, "进度不许被重置"); 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**(错位入也不许闪一下)。 /// 退场中途又回列表:接着当前进度往回走,**不许跳回 0**(错位入也不许闪一下)。
#[test] #[test]
fn a_row_that_returns_mid_exit_does_not_jump() { fn a_row_that_returns_mid_exit_does_not_jump() {
@@ -122,10 +122,13 @@ pub(super) fn rows_column<'a>(
// 于是末帧与静息逐像素相同。为什么不是「把 α 交给行内容」、为什么遮罩必须自成一层, // 于是末帧与静息逐像素相同。为什么不是「把 α 交给行内容」、为什么遮罩必须自成一层,
// 见 `motion/reveal.rs` 的模块文档(两件事都有实测记录)。 // 见 `motion/reveal.rs` 的模块文档(两件事都有实测记录)。
let veil = (visibility < 1.0).then(|| { 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 { iced::Color {
a: (1.0 - visibility).clamp(0.0, 1.0), a: (1.0 - visibility).clamp(0.0, 1.0),
..card ..under
} }
}); });
entries = entries.push( entries = entries.push(
@@ -10,6 +10,8 @@
//! | `rows/list.rs` 把 `keyed::Column` 换回 `Column`(按**下标**比对) | `inserting_a_row_keeps_every_unchanged_rows_node`(a/b/c 的树节点会全换一遍) | //! | `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 的节点) | //! | `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()` 会为真) | //! | `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` | //! | `rows/list.rs` 里不再按 `motion::rows::drawn()` 取行(改回遍历 `visible`) | `the_list_is_fed_by_the_pump_order` |
use std::any::Any; 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`:列表变化**当场就是最终态**,而且一帧都不要。 /// `MotionScale::NONE`:列表变化**当场就是最终态**,而且一帧都不要。
#[test] #[test]
fn no_scale_lands_on_the_final_state_without_frames() { fn no_scale_lands_on_the_final_state_without_frames() {