diff --git a/crates/app-iced/src/pages/download/install.rs b/crates/app-iced/src/pages/download/install.rs index d3c29ae..f91a561 100644 --- a/crates/app-iced/src/pages/download/install.rs +++ b/crates/app-iced/src/pages/download/install.rs @@ -129,13 +129,15 @@ pub(super) fn panel_state(model: &Download) -> PanelState<'_> { /// 落在切线上),在 `Narrow` 档则**在滚动区里**——窄档没有多余高度同时钉住「菜单 + 3 整行列表」, /// 而列表那 3 整行是 M125 像素守门的下限。两支的取舍都写在 `rows/tail.rs`。 /// -/// **两个 Shared 分支的现实不同,别一概而论**(`rows/tail.rs::TailShare::of`): +/// **两支「面板先保预算」的分支(`Shared` / `Narrow`)的现实不同,别一概而论** +/// (`rows/tail.rs::TailShare::of`): /// -/// - **三栏档 + 带加载器版本列表**:`has_loader_versions()` 要求 `problem.is_none()`,所以这一支 -/// 滚动区里**只会有 head + chips**,错误行与它不可达; -/// - **chips 档**:`TailShare::of` 对 `Options`/`Install` 无条件 `Narrow`,`problem` **可以是** -/// `Some`(静态不兼容 / 版本列表取不到 / 空表三种),那时错误行**在滚动区里**、可能被滚走; -/// `show_versions()` 为假时滚动区只剩 head + chips + 错误行,钉死区只有实例名。 +/// - **三栏档 + 带加载器版本列表**(`Shared`):`has_loader_versions()` 要求 `problem.is_none()`, +/// 所以这一支滚动区里**只会有 head + chips**,错误行与它不可达; +/// - **chips 档**(`Narrow`):`TailShare::of` 对 `Options`/`Install` 无条件 `Narrow`, +/// `problem` **可以是** `Some`(静态不兼容 / 版本列表取不到 / 空表三种),那时错误行 +/// **在滚动区里**、可能被滚走;`show_versions()` 为假时滚动区只剩 head + chips + 错误行, +/// 钉死区只有实例名。 pub(super) fn panel<'a>(ui: &'a Ui, catalog: Catalog, model: &'a Download) -> Element<'a, Message> { panel_with(ui, catalog, model, TailShare::Pinned) } diff --git a/crates/app-iced/src/pages/download/options/fade.rs b/crates/app-iced/src/pages/download/options/fade.rs index d3cb56e..9b6b9f7 100644 --- a/crates/app-iced/src/pages/download/options/fade.rs +++ b/crates/app-iced/src/pages/download/options/fade.rs @@ -37,15 +37,12 @@ pub(super) const VIEWPORT_FADE_PX: f32 = 12.0; /// 钉死区的实例名从 668 起,所以遮罩压不到任何控件(M227 返工的零回退证据就是逐像素 /// 差异只有 648–660 这 12 行)。 pub(super) fn viewport_fade<'a>(ui: &'a Ui, height: f32) -> Element<'a, Message> { - const STEPS: usize = 12; let canvas = ui.layer(MaterialLayer::Canvas); - let band_height = height / STEPS as f32; + let band_height = height / VIEWPORT_FADE_STEPS as f32; let mut bands = column![].width(Length::Fill); - for index in 0..STEPS { - // t:顶端 ≈0(几乎透出内容)→ 底端 = 1(完全是画布色,与视口外的背景接上)。 - let t = (index + 1) as f32 / STEPS as f32; - let alpha = canvas.a * t.powf(1.6); + for index in 0..VIEWPORT_FADE_STEPS { + let alpha = canvas.a * step_alpha(index); bands = bands.push( container(space::horizontal()) .width(Length::Fill) @@ -56,28 +53,73 @@ pub(super) fn viewport_fade<'a>(ui: &'a Ui, height: f32) -> Element<'a, Message> bands.into() } +/// 遮罩的条数:每条高 `VIEWPORT_FADE_PX / VIEWPORT_FADE_STEPS` 像素(现在是 1px)。 +/// +/// 单测直接对**这一对生产常量** + [`step_alpha`] 断言,所以改步数或改曲线都会当场变红 +/// ——M227 r2 复审实测过「单测空转」的形态:旧测试在函数体里又抄了一遍 `12`,改生产常量它照样绿。 +pub(super) const VIEWPORT_FADE_STEPS: usize = 12; + +/// 第 `index` 条(0 = 最上面、`VIEWPORT_FADE_STEPS - 1` = 视口底边)的**不透明度系数**。 +/// +/// 顶端 ≈0(几乎透出内容)→ 底端 = 1(完全是画布色,与视口外的背景接上);`t^1.6` 让前几条更淡, +/// 观感是「内容渐隐到背景」,不是一层均匀的纱。 +pub(super) fn step_alpha(index: usize) -> f32 { + // t:0 < t <= 1(`index + 1` 让顶端不落在 0 上,否则第一条完全看不见)。 + let t = (index + 1) as f32 / VIEWPORT_FADE_STEPS as f32; + t.powf(1.6) +} + #[cfg(test)] mod tests { use super::*; - /// 遮罩的几何自检:12 条 1px 恰好铺满 12px,且 α 单调不减(顶端近透明、底端不透明)。 + /// 遮罩的几何自检:**对生产常量断言**,不是对测试里抄的副本。 /// - /// 抓得住:把步数改成 6(每档 2px ⇒ 能看到阶梯)、或把 α 曲线写成常数(等于一层纯色幕)。 + /// 抓得住(r2 复审指出的空转形态:旧测试在函数体里又抄了一遍 `12`,改生产常量照样绿): + /// `VIEWPORT_FADE_STEPS` 改成 6(每档 2px ⇒ 见得到阶梯)、改成不能整除高度的数(半像素带)、 + /// `step_alpha` 写成常数(等于一层纯色幕)、把曲线拉直(`^1.0`,前几条不再更淡)、 + /// 或让末项小于 1(视口底边与画布之间留一道缝)。 #[test] - fn the_fade_steps_are_one_pixel_and_monotone() { - let steps = 12.0_f32; + fn the_fade_bands_are_one_pixel_and_the_ramp_is_convex_to_opaque() { + // ① 每条**恰好 1px**:这是设计口径(「12 条 1px 实色块」),也是「肉眼分不出阶梯」的前提。 + // 换成 6 条就是 2px/条、换成 5 条还会出半像素带 —— 两种都当场红。 + let band = VIEWPORT_FADE_PX / VIEWPORT_FADE_STEPS as f32; assert_eq!( - (VIEWPORT_FADE_PX / steps * steps * 100.0).round() / 100.0, - VIEWPORT_FADE_PX, - "步数要能整除 12,否则会出现半像素带" + VIEWPORT_FADE_STEPS as f32, VIEWPORT_FADE_PX, + "步数必须等于高度像素数:{VIEWPORT_FADE_PX}px 要 1px 一条,现在 {VIEWPORT_FADE_STEPS} 条 ⇒ {band}px/条" ); - let mut previous = -1.0_f32; - for index in 0..12 { - let t = (index + 1) as f32 / steps; - let alpha = t.powf(1.6); - assert!(alpha >= previous, "α 必须单调不减(第 {index} 档)"); + assert_eq!(band, 1.0, "每条恰好 1px"); + + // ② α 严格递增、除末条外都不透明(末条必须是画布色本身,与视口外无缝接上)。 + let mut previous = 0.0_f32; + let mut previous_delta = 0.0_f32; + for index in 0..VIEWPORT_FADE_STEPS - 1 { + let alpha = step_alpha(index); + let delta = alpha - previous; + assert!( + delta > 0.0, + "第 {index} 条的 α ({alpha}) 必须大于前一条 ({previous}):遮罩要单调变浓" + ); + assert!( + alpha < 1.0, + "第 {index} 条就已不透明,后面 {VIEWPORT_FADE_STEPS} 条白给" + ); + assert!( + delta >= previous_delta, + "第 {index} 条比前一条浓得更少({delta} < {previous_delta}):曲线要凸(t^1.6),否则顶端不再「几乎透出内容」" + ); previous = alpha; + previous_delta = delta; } - assert_eq!(previous, 1.0, "底端必须是完全不透明的画布色"); + assert!( + step_alpha(0) < 0.1, + "最上面一条 α = {} 不够淡:遮罩顶端要几乎透出内容", + step_alpha(0) + ); + assert_eq!( + step_alpha(VIEWPORT_FADE_STEPS - 1), + 1.0, + "最末一条必须是画布色本身(否则与视口外的背景之间有一道缝)" + ); } } diff --git a/crates/app-iced/src/pages/download/options/view.rs b/crates/app-iced/src/pages/download/options/view.rs index b702225..839bec6 100644 --- a/crates/app-iced/src/pages/download/options/view.rs +++ b/crates/app-iced/src/pages/download/options/view.rs @@ -97,7 +97,7 @@ pub(super) fn version_menu_height(items: usize) -> f32 { /// - `Pinned`(原版等不带版本列表的面板)走另一条路:标签、说明、菜单、实例名都在同一列里 /// 顺序铺开(`04` 基线图就是这一档,不能改); /// - `Shared`(三栏档 + 带版本列表): -/// - **滚动区**:head(版本 id + 安装按钮)+ 六枚加载器 chips(**chips 档**还可能有错误行,见下); +/// - **滚动区**:head(版本 id + 安装按钮)+ 六枚加载器 chips; /// - **钉死区**:说明行(有说明时;否则是「加载器版本」标签)+ 版本菜单 + 实例名输入框。 /// - `Narrow`(chips 档 + 面板占高度,M227): /// - **滚动区**:head + 六枚 chips + 说明行 + 错误行 + **版本菜单**; @@ -110,10 +110,13 @@ pub(super) fn version_menu_height(items: usize) -> f32 { /// 而 M125 的像素守门要求 ≥3 整行。所以窄档只钉实例名,其余进滚动区(预算算术见 /// `rows/tail.rs::CHIPS_PANEL_BUDGET`)。 /// -/// D30 八审实测(1180×760,`Shared`):滚动区可见高 ≈96–120px,内容(head 40 + chips 32 + +/// D30 八审实测(1180×760,`Shared`):滚动区可见高 ≈96–104px,内容(head 40 + chips 32 + /// 间距 4)≈76px ⇒ head(墨迹 462–501)与六枚 chips(506–537,32px 四角闭合)完整在首屏。 -/// M227 实测(720×760,`Narrow`):滚动区 120px ≥ 首屏 104(head 40 + 4 + chips 32 + 8 + -/// 说明行 20)⇒ head、六枚 chips、说明行三者完整在首屏,菜单露出容器上沿(有滚动条)。 +/// M227 在 tip 上按同一条判据复量(视口 = 面板内容顶 → 钉死区上一行 − 8px 间距): +/// 1180×760 = 462→563 = **101px**、1180×900 = 594→703 = **109px**、窄档 720×760 = 539→659 = +/// **120px**(r2 复审量的 116px 落在这个区间内,差别只在「钉死区从哪一行算起」;三档都远大于 +/// 首屏内容 76px,结论不变)。窄档那 120px 对首屏 104px(head 40 + 4 + chips 32 + 8 + 说明行 20) +/// 留 16px 余量 ⇒ head、六枚 chips、说明行三者完整在首屏,版本菜单露出容器上沿(滚动条在右)。 pub(crate) fn section<'a>( ui: &'a Ui, catalog: Catalog, diff --git a/crates/app-iced/src/pages/download/tests/options_shot/chips.rs b/crates/app-iced/src/pages/download/tests/options_shot/chips.rs index 2d1f51f..333a4ba 100644 --- a/crates/app-iced/src/pages/download/tests/options_shot/chips.rs +++ b/crates/app-iced/src/pages/download/tests/options_shot/chips.rs @@ -17,8 +17,11 @@ use super::{bands, feed, ui, Shot, CHIPS_SHOT}; /// /// 抓得住:①首行版本行的底色带 = 44(整行在屏);②**列表区 ≥ 3 整行**(面板上沿那条通栏 /// 分隔线就是下界)——这是窄档预算的硬下限:面板钉死区若重新钉住版本菜单(M227 之前的样子), -/// 它在 459px 里只能再留 1 整行给列表;③分隔线下方 96px 内有一条 ≥ 80px 的实色带(面板头那条 -/// 实心「安装」按钮 / 菜单容器的满宽底边)——「能换版本」与「能看见选项面板」是这条断言的成对验收。 +/// 它在 459px 里只能再留 1 整行给列表;③面板头部在屏:断言在面板上沿分隔线下方 96px 的窗口 +/// (分隔线 514 ⇒ 窗口 518–610)里找一条 ≥ 80px 的同色带,窄档实测命中 **y=550、宽 94px**, +/// 那是**实心「安装」按钮的药丸本身**——版本菜单容器的上沿在 636–639,落在窗口之外,与本条无关 +/// (r2 复审订正:旧注释把「菜单容器的满宽底边」也列成可能来源,那是错的)。 +/// 「能换版本」与「能看见选项面板」是这条断言的成对验收。 /// /// 抓不住:文本有没有被**横向**裁掉(那要看字形宽度,见 `tests/rails.rs` 的标签断言与 /// 报告里的量测表)、hover/tooltip 层(静态图拍不到)、面板内部滚动到底后长什么样