fix(download): 窄档滚动视口底部加 12px 淡出遮罩,去掉被硬裁的「空壳蓝条」(M227 返工)

D30 视觉审 hold 的必改项:滚动视口在 y=660 硬裁,把版本菜单的容器上沿切成 639–659 = 21px、
**零字形墨迹**的一条色块(深色下 (22,33,41) vs 画布 (9,21,28),CR 1.99),读起来像渲染错
而不是「下面还有内容可滚」。

改法(reviewer 推荐方案①,保留发现性):
- 新增 `options/fade.rs::viewport_fade` + `VIEWPORT_FADE_PX = 12`:12 条 1px、α 单调上升的
  **画布色**实色块拼成的斜坡(iced 0.14 没有渐变原语;1px 步进的差别在 1/255 量级);
- `options::section` 的窄档分支把滚动区包一层 `stack![滚动区, 底部对齐的遮罩]`,遮罩贴着
  视口内沿(≤660),压不到钉死区的实例名(≥668);
- 颜色取 `ui.layer(MaterialLayer::Canvas)`:浅色淡出到纸面、**深色淡出到墨底**(不是白雾),
  与它下方那块背景同色,所以两档都不新增颜色;
- 三栏档不铺这条遮罩:那里滚动区内容恒 76px < 视口 ≈118px,不会出现半截内容,铺了只是
  「画布盖画布」,反而会动 M222 已验的 1180 像素;
- `options/view.rs` 已 422 行、撞 400 行硬线,遮罩拆成 `options/fade.rs`(含一条几何自检单测)。

实测(720×760):
- 浅色 x=600 竖列 648 (211,229,245) → 659 (246,250,255) 单调升到画布色;深色 648 (55,72,85)
  → 659 (9,21,28),无白雾;中文与浅色逐值同串;
- 可见带 21px → 墨迹口径 18px + 底 12px 连续渐变(硬裁边消失,右侧滚动条仍在);
- 逐像素零回退:`77d675b`(遮罩前)vs 本轮 720 浅/深/中 + 700 守门,差异 bbox 只有
  (112,648)–(696,660) 这 12 行,其余像素一个都没动;1180×760 浅/深/中 与 1180×900 对
  M222 基线仍 **IDENTICAL**。

门禁:fmt ✓ / check ✓ / test --workspace(喂四张守门图)exit 0(app-iced lib 860 passed、
四条像素守门 + 400 行门全绿)/ clippy --workspace --all-targets -D warnings exit 0 /
.rs 全仓扫描 >400 行 0 个、<2 字节 0 个。

已知残留(本轮不动,按债务台账口径登记):menu 的 L2 阴影不随滚动视口裁切(压在实例名输入框上
约 6.9%,三栏档 fabric 同样存在)——根因在 `components::inputs::menu`,不在 M227 scope,
待专门 mission;finding `.tower/comms/findings/20261110-worker-m227-bug-l2.md`。
This commit is contained in:
AskaEth committed 2026-11-10 23:07:20 +08:00
1 parent a70d5f8010
commit 122accdd02
3 files changed
+119 -12

No files matched your search

@@ -27,6 +27,7 @@
//! 草稿(选中版本 + 三个选项)住在 `App`,由 `refresh_view` 带进页面模型——与分类筛选同一个做法,
//! 因为 `refresh_view` 每次事件都会把整棵模型重建,页面自己存不住任何东西。
mod fade;
mod model;
#[cfg(test)]
mod s1_tests;
@@ -0,0 +1,83 @@
//! 选项区滚动视口底部的**淡出遮罩**(M227 返工)。
//!
//! 单独一个文件只为守 400 行硬线(`components::gate::every_source_file_stays_under_the_line_limit`)。
use iced::widget::{column, container, space};
use iced::{Color, Element, Length};
use unlucky_design::elevation::MaterialLayer;
use crate::theme::Ui;
use super::super::frame;
use super::super::Message;
/// 窄档滚动视口底部的淡出高度(像素)。
///
/// 取值落在 8~12 这一段:视口在 660 处硬裁,版本菜单的容器上沿(639)露出来 21px 且**一个字形
/// 都没有**——D30 返工把它判成「空壳蓝条」(深色下容器 `(22,33,41)` vs 画布 `(9,21,28)`,
/// CR 1.99,像渲染错而不是「下面还有内容」)。12px 遮罩让最后 12px 单调渐变到画布色,
/// 留下 8px 圆角上沿做发现性提示。
pub(super) const VIEWPORT_FADE_PX: f32 = 12.0;
/// 滚动视口底部的**淡出遮罩**:把内容在最后 `height` 像素内渐变到**画布色**。
///
/// 为什么需要它:`scrollable` 会硬裁内容(渲染层的 `with_layer`),裁切位置落在什么上面是不确定的
/// ——窄档正落在版本菜单容器上沿,用户看到的是一条没有字形的色块,而不是「还能往下滚」。
///
/// 为什么是**画布色的 α 阶梯**而不是白/黑雾:遮罩的颜色必须等于它下方那块背景的颜色,否则
/// 浅色档会浮出一层灰幕、深色档会浮出白雾。`ui.layer(MaterialLayer::Canvas)` 就是页面底色
/// (含面板 α),于是浅色淡出到纸面、深色淡出到墨底,两档都不新增颜色;方向也对
/// (内容向背景淡出,不是叠一层高光)。
///
/// 为什么用 12 条 1px 实色块拼:iced 0.14 没有渐变原语。1px 步进的 α 斜坡在 100% DPI 下与
/// 连续渐变的差别在 1/255 量级——D30 的逐像素量测只会看到 α 单调、无硬边。
///
/// 形状上它必须**贴在视口内**(调用侧把它放在滚动区底部的 `stack!` 上):视口下沿是 660,
/// 钉死区的实例名从 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 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);
bands = bands.push(
container(space::horizontal())
.width(Length::Fill)
.height(Length::Fixed(band_height))
.style(move |_theme| frame::filled(Color { a: alpha, ..canvas })),
);
}
bands.into()
}
#[cfg(test)]
mod tests {
use super::*;
/// 遮罩的几何自检:12 条 1px 恰好铺满 12px,且 α 单调不减(顶端近透明、底端不透明)。
///
/// 抓得住:把步数改成 6(每档 2px ⇒ 能看到阶梯)、或把 α 曲线写成常数(等于一层纯色幕)。
#[test]
fn the_fade_steps_are_one_pixel_and_monotone() {
let steps = 12.0_f32;
assert_eq!(
(VIEWPORT_FADE_PX / steps * steps * 100.0).round() / 100.0,
VIEWPORT_FADE_PX,
"步数要能整除 12,否则会出现半像素带"
);
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} 档)");
previous = alpha;
}
assert_eq!(previous, 1.0, "底端必须是完全不透明的画布色");
}
}
@@ -23,6 +23,7 @@
//! [原版][Forge][NeoForge][Fabric][Quilt][LiteLoader]
//! Vanilla parent only — Forge goes through the CLI
//! ┌ 1.12.2-SNAPSHOT ┐ ← 露在折线下方,滚得到(滚动条在右)
//! ── 视口底 12px 淡出遮罩(`fade::viewport_fade`)────
//! ── 钉死区 ──────────────────────────────────────
//! [实例名 ]
//! ```
@@ -31,7 +32,10 @@
//! 顺序铺开(`04` 基线图就是这一档,不能改);
//! - 不兼容时的原因行(Error 色)在滚动区末尾(**窄档**可达;三栏档 + 带版本列表时
//! `problem` 恒为 `None`,所以那支看不到它);
//! - 说明只有 LiteLoader 有(`Key::DownloadOptionsLiteLoaderVanillaOnly`)。
//! - 说明只有 LiteLoader 有(`Key::DownloadOptionsLiteLoaderVanillaOnly`);
//! - 窄档的滚动视口底边会硬裁内容(M227 返工前正切在版本菜单容器上沿,露出 21px 没有字形的
//! 色块),所以那里铺一条 12px 淡出遮罩把内容渐变到画布色——遮罩在视口内(≤660),
//! 压不到钉死区的实例名(≥668)。
//!
//! ## 为什么加载器是**六枚一行**(M222 D30 r1 实测订正)
//!
@@ -46,7 +50,7 @@
//! 现在的做法:五家永远都能点,选中不兼容的那家时下面出现一行具名原因,同时「安装」变成禁用态。
//! 于是「为什么不能装」和「怎么才能装」永远同屏。
use iced::widget::{column, container, row, scrollable};
use iced::widget::{column, container, row, scrollable, stack};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
@@ -65,6 +69,7 @@ use crate::theme::Ui;
use super::super::frame;
use super::super::rows::TailShare;
use super::super::Message;
use super::fade;
use super::model::{Loader, LoaderVersionsView, OptionsView};
// 加载器 chip 的顺序与集合**委派**给 `Loader::ALL`(原版 / Forge / NeoForge / Fabric /
@@ -240,16 +245,34 @@ pub(crate) fn section<'a>(
Message::SetInstanceName,
));
column![
scrollable(body)
.height(Length::Fill)
.style(frame::scroll_style(ui)),
pinned,
]
.spacing(ui.space(Space::X2))
.height(Length::Fill)
.width(Length::Fill)
.into()
let scroll_area: Element<'a, Message> = scrollable(body)
.height(Length::Fill)
.style(frame::scroll_style(ui))
.into();
// 窄档:视口底部铺一条 12px 淡出遮罩(M227 返工 ①,理由见 `fade::viewport_fade`)。
// 三栏档不铺:那里滚动区内容恒为 head 40 + 间距 4 + chips 32 = 76px,而视口 ≈118px,
// 永远不会出现「半截内容」;铺上去只是「画布盖画布」,反而动了 M222 已验的 1180 像素。
let scroll_area: Element<'a, Message> = if narrow {
stack![
scroll_area,
container(fade::viewport_fade(ui, fade::VIEWPORT_FADE_PX))
.width(Length::Fill)
.height(Length::Fill)
.align_y(Alignment::End),
]
.width(Length::Fill)
.height(Length::Fill)
.into()
} else {
scroll_area
};
column![scroll_area, pinned]
.spacing(ui.space(Space::X2))
.height(Length::Fill)
.width(Length::Fill)
.into()
}
/// 版本菜单上方那一行:**有中性说明就显示说明**(它讲的是「这一家的叠法」,位置就在版本菜单