fix(app-iced): 面板预算按内容推导 + 版本菜单不画 L2 阴影(M238)

两条都在「视口不终止内容」这一族里,修法各一半:

① 三栏档面板预算从死的 `PANEL_BUDGET = 300` 改成 `panel_budget(menu_items)`
   (内边距 24 + 首屏 76 + 间距 8 + 钉死区 88 + 菜单高 + 列表↔面板 21)。
   旧常数不含列表↔面板那 21、也不随版本菜单行数变,于是 760/900 档面板实得
   286/294px,滚动视口被挤到 54/62px——六枚 loader chip 只露出 10px(736 与 1180
   同样严重,纵向预算与窗宽无关)。修后 760 档视口 98px(chips 31px 整枚在屏)、
   900 档 106px;列表按整行让出:760 档 4→3 整行、900 档 7→6 整行;只有一条
   加载器版本时菜单只占 64px,列表仍是 4 整行。

② 版本菜单改用 `Menu::flat()`:不是「把阴影裁掉」,而是**不在滚动容器里画阴影**。
   `iced_tiny_skia` 0.14 画 quad 阴影走 `draw_pixmap(.., mask: None)`
   (engine.rs:136-143),绕过裁切掩码;`iced_wgpu` 那边是整层 scissor
   (quad.rs:96-101)⇒ 同一棵树两个后端画出来不一样。修前菜单底下 54px 一条被压暗
   (浅色 249→231,−7.2%,含 8px 缝与实例名框内上半部),修后逐像素等于画布色。
   菜单的 L2 令牌不动,真实浮层照旧带阴影。

判据(两条,抓得住的/抓不住的都写进文档注释):
- 结构:`rows/tail.rs` 断言三栏档 760/900 的滚动视口 ≥ 首屏 76px(对生产常量做算术);
- 像素:`options_shot/compact.rs` 断言 chip 描边上下两行都在、且实例名框上方那条缝
  与框内都是纯画布色。
变异自证:预算退回 300 ⇒ 结构断言红("滚动视口只有 54px < 首屏 76px");阴影无视
`flat()` ⇒ 像素断言红("y=660 x=304 是 [231,231,232] 而不是画布色")。

没验:滚动中/底部三态拍不出来(harness 无 `scroll_to` 派发通道,已另开 finding)。
This commit is contained in:
AskaEth committed 2026-11-11 08:28:06 +08:00
1 parent dbc5274965
commit df0944894f
11 files changed
+454 -69

No files matched your search

@@ -36,4 +36,4 @@ mod tests;
mod view;
pub use model::{InstallDraft, Loader, OptionsView};
pub(crate) use view::section;
pub(crate) use view::{section, version_menu_height};
@@ -325,4 +325,18 @@ impl OptionsView {
pub fn loader_version_arg(&self) -> Option<&str> {
(!self.loader_version.is_empty()).then_some(self.loader_version.as_str())
}
/// 版本菜单里会有**几条**(`0` = 这一态不画菜单)。
///
/// 与 [`OptionsView::has_loader_versions`] 同源:判据一致(不是原版、没有静态原因、
/// 列表真的就位),所以 `>0` ⟺ `has_loader_versions()`。
/// 用途(M238):三栏档的面板预算按菜单高**推导**(`rows/tail.rs::panel_budget`)——
/// 菜单一行还是两行,差的是一整行列表(48px)。
#[must_use]
pub fn loader_version_count(&self) -> usize {
match &self.versions {
LoaderVersionsView::Ready(items) if self.has_loader_versions() => items.len(),
_ => 0,
}
}
}
@@ -84,7 +84,7 @@ use super::model::{Loader, LoaderVersionsView, OptionsView};
/// 为什么按**条目数**取(M222 D30 r1 S1):LiteLoader 对 1.12.2 只有一条版本,原来固定撑到
/// 两行高(112px),那 48px 纯属空档,却正好是把上面版本列表挤成 2.4 行的元凶之一。
/// 单测 `options/view::tests` 直接钉这两档的数值。
pub(super) fn version_menu_height(items: usize) -> f32 {
pub(crate) fn version_menu_height(items: usize) -> f32 {
const ITEM: f32 = 48.0;
let rows = items.clamp(1, 2) as f32;
rows * ITEM + Space::X2.dp() * 2.0
@@ -112,11 +112,11 @@ pub(super) fn version_menu_height(items: usize) -> f32 {
///
/// D30 八审实测(1180×760,`Shared`):滚动区可见高 ≈96–104px,内容(head 40 + chips 32 +
/// 间距 4)≈76px ⇒ head(墨迹 462–501)与六枚 chips(506–537,32px 四角闭合)完整在首屏。
/// 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、说明行三者完整在首屏,版本菜单露出容器上沿(滚动条在右)。
/// **M238 复量**(同一判据:视口 = 面板内容顶 → 钉死区上一行 − 8px 间距):1180×760 与 736×760
/// 修前都只有 **54px**(这解释了「六枚 chip 只露 10px」——不是 736 独有),修后 **98px**;
/// 900 档修前 62px、修后 106px。差数来源见 `rows/tail.rs::panel_budget`。
/// 窄档 720×760 = **120px**,对首屏 104px(head 40 + 4 + chips 32 + 8 + 说明行 20)留 16px 余量
/// ⇒ head、六枚 chips、说明行三者完整在首屏,版本菜单露出容器上沿(滚动条在右)。
pub(crate) fn section<'a>(
ui: &'a Ui,
catalog: Catalog,
@@ -354,7 +354,11 @@ fn version_list<'a>(
LoaderVersionsView::Failed(_) => frame::gap(0.0),
LoaderVersionsView::Ready(items) if items.is_empty() => frame::gap(0.0),
LoaderVersionsView::Ready(items) => {
let mut list = Menu::new().width(Length::Fill);
// **内联列表口径**(M238):这张菜单被套在下面的 `scrollable` 里,而 CPU 后端画
// quad 阴影时绕过裁切掩码 ⇒ L2 阴影会漏出视口、把钉死区的实例名压暗 7.2%
// (浅色画像布 `(249,249,250)` → `(231,231,232)`)。理由与两后端差异见
// `components::inputs::menu::shadow` 的 `flat` 文档。
let mut list = Menu::new().width(Length::Fill).flat();
for item in items {
let mut entry =
MenuItem::new(item.as_str(), Message::SetLoaderVersion(item.clone()));
+7 -10
View File
@@ -78,7 +78,10 @@ pub(super) fn three_column<'a>(
body_height: f32,
) -> Element<'a, Message> {
// 分高方式与列表高度都从 `TailShare` 一处取(`Pinned` 档不给整行高度)。
let share = rows::TailShare::of(rails.tier, &super::install::panel_state(model));
// M238:面板预算按版本菜单的行数推,所以 `list_height` 也要拿到那个条数。
let state = super::install::panel_state(model);
let share = rows::TailShare::of(rails.tier, &state);
let list_height = share.list_height(body_height, rows::menu_items(&state));
row![
panel(
ui,
@@ -90,15 +93,9 @@ pub(super) fn three_column<'a>(
rails.major_rail,
major_rail(ui, catalog, model, rails.major_rail)
),
container(rows::content(
ui,
catalog,
model,
share,
share.list_height(body_height),
))
.width(Length::Fixed(rails.list))
.height(Length::Fill),
container(rows::content(ui, catalog, model, share, list_height))
.width(Length::Fixed(rails.list))
.height(Length::Fill),
]
.spacing(super::super::layout::rails::RAIL_GAP)
.width(Length::Fill)
+5 -3
View File
@@ -35,8 +35,10 @@
//! (`tail::narrow_rows` + `CHIPS_PANEL_BUDGET = 192`):700/720×760 下列表 **132px = 3 整行**、
//! 面板拿走其余(208px)。M227 之前这一档是「Fill 与 Fill 五五分」——面板只分到 164px,
//! 钉死区(说明 + 菜单 + 实例名 ≈180px)把它吃穿,滚动区剩 0、head / 六枚 chip 全不在屏。
//! 三栏档 + 带版本列表的选项区走另一支(`tail::whole_rows` + `PANEL_BUDGET = 300`):
//! 1180×760 列表 **176px = 4 整行**、面板 ≈294px,那里菜单与说明都钉在面板底部。
//! 三栏档 + 带版本列表的选项区走另一支(`tail::whole_rows` + `tail::panel_budget`):
//! 1180×760 列表 **132px = 3 整行**、面板 330px(滚动视口 98px),那里菜单与说明都钉在面板底部。
//! M238 之前那支用的是**死的** `PANEL_BUDGET = 300`:面板实得 286px,滚动视口只有 54px
//! ⇒ 六枚 chip 被裁成 10px 的上缘(736 与 1180 同病,纵向预算与窗宽无关)。
//! 「刚进页面还没选版本」(`Hint`,面板只有一行提示)**不共享**:那一态 main 本来就有
//! **7 整行**在屏(不是 8:第 8 条墨迹其实是 `Pick a version, then install` 那句 hint,
//! 横向范围对不上版本号;700×760 上放 8 行要 352px,实测只剩 309~322px),五五分会砍成
@@ -85,7 +87,7 @@ use super::states;
mod tail;
pub(crate) use tail::TailShare;
pub(crate) use tail::{menu_items, TailShare};
/// 列表列(三栏档与 chips 档共用一份)。
///
+149 -31
View File
@@ -1,10 +1,14 @@
//! 「列表与尾巴面板怎么分高」+ 版本行的整行取整(M125 / M222 D30 / M227)。
//! 「列表与尾巴面板怎么分高」+ 版本行的整行取整(M125 / M222 D30 / M227 / M238)。
//!
//! 从 `rows.rs` 拆出来的理由只有一条:那边撞 400 行硬线(项目自带
//! `components::gate::every_source_file_stays_under_the_line_limit` 会判红)。本文件是
//! [`TailShare`] 的**纯搬移**,加上 M222 新增的三个只读量([`ROW_PITCH`] / [`LIST_CHROME`] /
//! [`whole_rows`])与 M227 的 chips 档预算([`CHIPS_STACK`] / [`CHIPS_LIST_CHROME`] /
//! [`CHIPS_PANEL_BUDGET`] / [`narrow_rows`])。
//!
//! M238 把三栏档那个钉死的 `PANEL_BUDGET = 300` 换成**按面板内容推导**的 [`panel_budget`]
//! (首屏 + 钉死区 + 内外边距 + 列表↔面板那层),理由:那个常数不含 [`TAIL_CHROME`]、
//! 也不随版本菜单行数变,760/900 档下滚动视口只剩 54/62px,六枚 chip 被裁成 10px。
use crate::pages::layout::VersionRailTier;
@@ -15,7 +19,7 @@ use crate::pages::download::install::PanelState;
/// | 档位 × 面板形态 | 分法 | 为什么 |
/// |---|---|---|
/// | `Wide` / `Narrow` / `Compact` + 面板**不带**加载器版本列表 | [`TailShare::Pinned`] | 原版面板矮(`04` 实测列表 6 行),整块在屏内 |
/// | 任何档 + 选项区**带加载器版本列表** | [`TailShare::Shared`]:面板先吃 [`PANEL_BUDGET`],剩下按整行给列表([`whole_rows`]) | 带版本列表的选项区实高 ≈294~307px;旧规则(对半分)把列表压到 110px,现在 1180×760 是 **176px = 4 整行** |
/// | 任何档 + 选项区**带加载器版本列表** | [`TailShare::Shared`]:面板先吃 [`panel_budget`],剩下按整行给列表([`whole_rows`]) | 面板要「首屏 76 + 钉死区(说明 + 菜单 + 实例名)」才算完整;旧规则(对半分)把列表压到 110px,M238 前那个常数 300 又把滚动视口挤到 54px(六枚 chip 只露 10px)。现在 1180×760 是 **132px = 3 整行**、视口 98px |
/// | `Chips` + 面板**占高度**(选项区展开 / 安装中) | [`TailShare::Narrow`]:面板先吃 [`CHIPS_PANEL_BUDGET`],剩下按整行给列表([`narrow_rows`]) | 窄档可用高比三栏档少两行 chip(459 vs 580)⇒ 只钉实例名,head/chips/说明/菜单进滚动区;700/720×760 列表 **132px = 3 整行** |
/// | 任何档 + 只画 `Hint`(没选版本,面板只有一行) | [`TailShare::Pinned`] | 一行提示不值得让面板吃预算:700×760 下 main 是 **7 整行**在屏(不是 8——第 8 条墨迹其实是 `Pick a version, then install` 那句 hint,横向范围对不上版本号;要放 8 行得 352px,实测只剩 309~322px),对半分只剩 **4 整行**、下面留 160px 空白(r1 复审 P2-1,r2 修回 7) |
///
@@ -42,7 +46,7 @@ impl TailShare {
/// 1. `Chips`(可用宽 < 600,多花两行 chip、竖排预算最紧的那一档)**且**面板真占高度
/// (`Options` 选中了版本 / `Install` 进度或失败)⇒ [`TailShare::Narrow`];
/// 2. **任何档位**的 `Options` 只要 [`has_loader_versions`](super::super::options::OptionsView::has_loader_versions)
/// (选项区带加载器版本列表 ⇒ 实高 ≈294~307px)⇒ [`TailShare::Shared`]。三栏档只走这条,
/// (选项区带加载器版本列表 ⇒ 钉死区 152~200px,见 [`panel_budget`])⇒ [`TailShare::Shared`]。三栏档只走这条,
/// chips 档被 arm 1 先接走;
/// 3. 其余 ⇒ [`TailShare::Pinned`]:不带版本列表的 `Options`(原版 / 不兼容 / 空表)与
/// `Hint`。
@@ -59,9 +63,10 @@ impl TailShare {
}
// M222 D30 r1/r2:**带加载器版本列表**的选项区在任何档位都太高。三栏档修前
// 面板 204→372px,版本列表被压到 2.4 行(110px)且底缘切在第三行图标上。
// 现在走 Shared:面板先吃 `PANEL_BUDGET`,列表按**整行**取(`whole_rows`,
// 标定 44/97)⇒ 1180×760 列表 **176px = 4 整行**、面板 ≈294px;
// 900 档 308px = 7 整行。实例名与版本菜单都在面板钉死区(`options::section`)。
// 现在走 Shared:面板先吃 `panel_budget`(按菜单行数推,M238),列表按**整行**取
// (`whole_rows`,标定 44/97)⇒ 1180×760 列表 **132px = 3 整行**、面板 330px、
// 滚动视口 98px(首屏 76 整块在屏);900 档 264px = 6 整行、视口 106px。
// 实例名与版本菜单都在面板钉死区(`options::section`)。
(_, PanelState::Options(options)) if options.has_loader_versions() => Self::Shared,
_ => Self::Pinned,
}
@@ -72,10 +77,13 @@ impl TailShare {
/// `column_height` 两个分支的入参口径一致:都传
/// [`frame::body_height`](crate::pages::frame::body_height) 的返回值
/// (chips 档上方那两行 chip 由 [`narrow_rows`] 自己扣,调用点不用管)。
pub(crate) fn list_height(self, column_height: f32) -> Option<f32> {
///
/// `menu_items` 只有 `Shared` 档用得上(面板预算按版本菜单的行数推,见 [`panel_budget`]);
/// 其余两支忽略它——调用点统一传 [`menu_items`] 的返回值即可。
pub(crate) fn list_height(self, column_height: f32, menu_items: usize) -> Option<f32> {
match self {
Self::Pinned => None,
Self::Shared => Some(whole_rows(column_height)),
Self::Shared => Some(whole_rows(column_height, menu_items)),
Self::Narrow => Some(narrow_rows(column_height)),
}
}
@@ -89,34 +97,84 @@ impl TailShare {
/// `Narrow` 两支都按整行取高,底缘落在行边界上。
pub(crate) const ROW_PITCH: f32 = 44.0;
/// 三栏档「列表以外、列内要扣掉的固定占位」(**列顶 156 → 列表顶 253**,D30 三审实测 = 97)。
/// 三栏档「列表以外、列内要扣掉的固定占位」(**列顶 156 → 列表顶 253**,D30 三审实测 = 97
/// = 搜索条 56 + 间距 8 + 分隔线 1 + 间距 8 + 计数头 16 + 间距 8)。
///
/// 1180×760:正文列 580 ⇒ floor((580−97−300) / 44) × 44 = **176px = 4 整行**,分隔线落在 437。
/// 1180×900:floor((720−97−300) / 44) × 44 = **308px = 7 整行**(八审实测分隔线 569)。
/// 1180×760:正文列 580,面板按内容要 [`panel_budget`] ⇒ 列表 **132px = 3 整行**(M238 前是
/// 176px = 4 整行,代价见 [`panel_budget`])。
/// **改搜索条 / 表头高度时要同步这个数。**
pub(crate) const LIST_CHROME: f32 = 97.0;
/// 三栏档面板(滚动区 + 钉死区)的**最小预算**。
/// 面板自身的上下内边距之和(`install::panel_with` 的 `Padding::from([Space::X3, 0])` = 12 + 12)。
pub(crate) const PANEL_PAD_V: f32 = 24.0;
/// 面板滚动区的**首屏**:head 40 + 间距 4 + 六枚 loader chip 32 = **76**。
///
/// 滚动区(八审后只剩 head 40 + 六枚 chips 32 + 间距 4 = **76**);
/// 钉死区 = 说明/标签行 20 + 间距 8 + 菜单 64 + 间距 8 + 实例名 56 = **156**。
/// 取 300 覆盖两者(76 + 156 = 232)再留余量给 chips 底边框与滚动条。
/// D30 五审实测:上一版「对半分」把滚动区压到 82px——宽档切「安装选项」字身、六枚 chip
/// 0 像素;窄档 24px 全被留白吃掉。窄档另有一套更紧的预算,见 [`CHIPS_PANEL_BUDGET`]。
pub(crate) const PANEL_BUDGET: f32 = 300.0;
/// 「首屏整块在屏」是本档的硬下限:低于它,chips 就被视口裁成一条缝(M238 实测 736×760
/// 修前只剩 **10px**),而用户看不出「下面还有六枚 chip」——那正是 M227 在 chips 档消灭过的形态。
pub(crate) const FIRST_SCREEN: f32 = 76.0;
/// 选项区里「滚动区 ↔ 钉死区」的间距(`options::section` 的 `Space::X2`)。
pub(crate) const SECTION_GAP: f32 = 8.0;
/// `Shared` 档钉死区里**除版本菜单以外**的固定占位 = 说明/标签 16 + 间距 8 + 间距 8 + 实例名 56。
pub(crate) const PINNED_CHROME: f32 = 88.0;
/// 列表与面板之间那一层固定占位(`rows::content` 的 `tail`:间距 8 + 分隔线 1 + 间距 12)。
pub(crate) const TAIL_CHROME: f32 = 21.0;
/// 正文列高度 → 列表视口的**整行高度**(给三栏档的 `Shared` 分支用)。
///
/// 分高规则从「对半分」改成「**面板先保预算,剩下的按整行给列表**」:760 档
/// (正文列 580)⇒ 580 − 97 − 300 = 183 ⇒ floor(183/44)×44 = **176px = 4 整行**;
/// 900 档 ⇒ 720 − 97 − 300 = 323 ⇒ **308px = 7 整行**(五审在 900 已验 7 行)。
/// 公式不变的部分是「列表只吃整行」,变的是分子——这正是塔要的「一次性预算重分配」。
/// 分高规则是「**面板按内容要够,剩下的按整行给列表**」——[`panel_budget`] 算的就是「够」
/// (首屏 76 + 钉死区 + 面板内外边距),公式本身从 D30 起没变,变的是分子。
///
/// | 正文列 | 菜单行数 | 列表 | 面板 | 滚动视口 |
/// |---|---|---|---|---|
/// | 580(760 窗) | 2 行(≥2 条版本) | **132 = 3 整行** | 330 | **98** |
/// | 580 | 1 行(只有 1 条版本) | 176 = 4 整行 | 286 | 102 |
/// | 720(900 窗) | 2 行 | 264 = 6 整行 | 338 | 106 |
/// | 720 | 1 行 | 308 = 7 整行 | 294 | 110 |
///
/// M238 之前这里钉死 `PANEL_BUDGET = 300`(不含 [`TAIL_CHROME`]):760/900 档面板实得
/// 286/294px,滚动视口被挤到 **54/62px**,六枚 chip 只露出 10px。预算必须**按内容推**,
/// 不能是一个跟菜单行数无关的常数。
#[must_use]
pub(crate) fn whole_rows(column_height: f32) -> f32 {
let room = (column_height - LIST_CHROME - PANEL_BUDGET).max(ROW_PITCH);
pub(crate) fn whole_rows(column_height: f32, menu_items: usize) -> f32 {
let room = (column_height - LIST_CHROME - panel_budget(menu_items)).max(ROW_PITCH);
(room / ROW_PITCH).floor() * ROW_PITCH
}
/// 三栏档面板「首屏整块 + 钉死区完整」要多少高(含列表↔面板那层 [`TAIL_CHROME`])。
///
/// = 内边距 24 + 首屏 76 + 间距 8 + 钉死区(88 + 菜单高)+ 列表↔面板 21。
///
/// - 菜单 2 行(`≥2` 条加载器版本,`version_menu_height` 封顶两行)⇒ **329**;
/// - 菜单 1 行(只有一条)⇒ **281**。
///
/// 为什么把 [`TAIL_CHROME`] 算进来:它在**同一列**里吃高度,但不在面板盒子里。M238 前那个
/// 300 没算它,于是「预算 300」实际只换来 **286px** 的面板(300 − 21 那层,再算上整行取整
/// 多出的 7px),比面板需要的 308 少 22px——那 22px 就是从滚动视口里借的。
#[must_use]
pub(crate) fn panel_budget(menu_items: usize) -> f32 {
PANEL_PAD_V
+ FIRST_SCREEN
+ SECTION_GAP
+ PINNED_CHROME
+ crate::pages::download::options::version_menu_height(menu_items)
+ TAIL_CHROME
}
/// 面板里那张版本菜单有几条(决定菜单高、进而决定 [`panel_budget`])。
///
/// `0` = 这一态不画菜单(`Hint` / `Install` / 原版 / 不兼容 / 取不到版本表);`Shared` 档只在
/// `has_loader_versions()` 成立时才走,所以那里恒 ≥ 1。
pub(crate) fn menu_items(state: &PanelState<'_>) -> usize {
match state {
PanelState::Options(options) => options.loader_version_count(),
PanelState::Hint | PanelState::Install(_) => 0,
}
}
/// chips 档(可用宽 < 600)里 `rows::content` **上方**那一坨的实测高(M227)。
///
/// 分类 tabs 48 + 间距 8 + 主版本 chip 条 48 + 间距 8 + 分隔线 1 + 间距 8 = **121**
@@ -183,18 +241,78 @@ mod tests {
);
}
/// 三栏档那支不动(M227 回归钉子):1180×760 = 176(4 整行)、900 高 = 308(7 整行)。
/// **M238 的结构判据**:三栏档在 760 / 900 两档正文列下,面板的滚动视口都 ≥ 首屏 76。
///
/// 视口 = 面板高 − [`PANEL_PAD_V`] − [`SECTION_GAP`] − 钉死区([`PINNED_CHROME`] + 菜单高),
/// 面板高 = 正文列 − [`LIST_CHROME`] − 列表 − [`TAIL_CHROME`]。这条算术就是渲染层真正的
/// 分高链路(`rails::three_column` → `rows::content` → `install::panel_with` → `options::section`),
/// 每个加数都取自生产常量,不是测试里抄的副本。
///
/// 抓得住:把 `panel_budget` 退回「死的 300」(= M238 前的形态)⇒ 760 档视口 54、900 档 62,
/// 两条都红;把预算改成不含 [`TAIL_CHROME`] ⇒ 两者再各少 21;把 [`FIRST_SCREEN`] 调小
/// (比如有人以为首屏只需 head + chips = 72)不明显红,但那属于口径调整,不属于回归。
/// 抓不住:像素上 chips 到底露出几行(那要真图,见 `tests/options_shot/compact.rs`);
/// 也不管 chips 档(`narrow_rows` 那一支另有它的钉子)。
#[test]
fn the_three_column_budget_is_unchanged() {
assert_eq!(whole_rows(580.0), 176.0);
assert_eq!(whole_rows(720.0), 308.0);
fn the_three_column_panel_keeps_its_first_screen_at_760_and_900() {
let pinned = |items: usize| {
PINNED_CHROME + crate::pages::download::options::version_menu_height(items)
};
let viewport = |body: f32, items: usize| {
let list = whole_rows(body, items);
let panel = body - LIST_CHROME - list - TAIL_CHROME;
panel - PANEL_PAD_V - SECTION_GAP - pinned(items)
};
// 硬判据先跑:**滚动视口 ≥ 首屏**(这是这条测试存在的理由,先红先看)
for (body, items) in [(580.0, 3), (580.0, 1), (720.0, 3), (720.0, 1)] {
assert!(
viewport(body, items) >= FIRST_SCREEN,
"正文列 {body} / 菜单 {items} 条:滚动视口只有 {}px < 首屏 {FIRST_SCREEN}px ⇒ \
六枚 chip 会被裁成一条缝(M238 修前 760 档实测 54px / chips 只露 10px)",
viewport(body, items)
);
}
// 紧接着钉住具体像素:它们让「视口够」不是靠偶然的取整余量。
// 760 窗(正文列 580):2 行菜单 = 3 整行列表;1 行菜单可以把第 4 行留下。
assert_eq!(
whole_rows(580.0, 3),
132.0,
"≥2 条版本 ⇒ 2 行菜单 ⇒ 3 整行列表"
);
assert_eq!(
whole_rows(580.0, 1),
176.0,
"只有 1 条版本 ⇒ 菜单 1 行 ⇒ 4 整行列表"
);
assert_eq!(viewport(580.0, 3), 98.0);
assert_eq!(viewport(580.0, 1), 102.0);
// 900 窗(正文列 720)
assert_eq!(whole_rows(720.0, 3), 264.0);
assert_eq!(whole_rows(720.0, 1), 308.0);
assert_eq!(viewport(720.0, 3), 106.0);
assert_eq!(viewport(720.0, 1), 110.0);
}
/// 三栏档的预算**跟着菜单行数走**(M238):菜单多一行,预算多 48,列表少一行。
///
/// 抓得住:`whole_rows` 的 `menu_items` 参数被忽略(两档预算相等);`panel_budget` 漏掉
/// [`TAIL_CHROME`](329 → 308);`PINNED_CHROME` 写小(那会让视口虚高、真图露馅)。
#[test]
fn the_three_column_budget_follows_the_version_menu_height() {
assert_eq!(panel_budget(1), 281.0, "24 + 76 + 8 + 88 + 64 + 21");
assert_eq!(panel_budget(2), 329.0, "菜单封顶两行 ⇒ 112");
assert_eq!(panel_budget(3), panel_budget(2), "3 条以上也还是两行");
assert_eq!(panel_budget(2) - panel_budget(1), 48.0, "一行的差 = 项高");
}
/// `list_height` 把三档的「列表吃多少」收敛到一处(`Pinned` = 不给定高)。
#[test]
fn list_height_follows_the_share() {
assert_eq!(TailShare::Pinned.list_height(580.0), None);
assert_eq!(TailShare::Shared.list_height(580.0), Some(176.0));
assert_eq!(TailShare::Narrow.list_height(580.0), Some(132.0));
assert_eq!(TailShare::Pinned.list_height(580.0, 0), None);
assert_eq!(TailShare::Shared.list_height(580.0, 3), Some(132.0));
assert_eq!(TailShare::Narrow.list_height(580.0, 0), Some(132.0));
}
}
@@ -1,7 +1,54 @@
//! Compact 档(可用宽 600~675)主版本栏的像素守门:M125 缺陷 2。
//! Compact 档(可用宽 600~675)的像素守门:M125 缺陷 2(主版本栏)+ M238(面板首屏 / 阴影越界)。
//!
//! M238 两条都钉在**同一张 736×760 图**上(`04-…-compact736.png`):
//! ①六枚 loader chip 整枚在屏(修前被面板预算挤得只剩 10px 高的上缘);
//! ②版本菜单的 L2 阴影不许漏出滚动视口、压暗下面钉死区的实例名(修前浅色画面布 `(249,249,250)`
//! 被压成 `(231,231,232)`,−7.2%)。
use unlucky_design::color::Role;
use super::{bands, feed, ui, Shot, COMPACT_SHOT};
/// iced 的 `Color` → 截图里的 8 位 RGB(tiny-skia 侧就是 `round(分量 × 255)`,截图不带 α)。
fn rgb(color: iced::Color) -> [u8; 3] {
let channel = |value: f32| (value.clamp(0.0, 1.0) * 255.0).round() as u8;
[channel(color.r), channel(color.g), channel(color.b)]
}
/// `y` 这一行里有多少段(每段 ≥ [`PILL_RUN_MIN`])与 `color` 同色的连续像素。
///
/// 用来区分「一排药丸的描边」与「一条通栏分隔线」:两者可能是同一个颜色(分隔线也用
/// `OutlineVariant`),但药丸是**多段**、分隔线是**一段通到底**。
fn pill_runs(shot: &Shot, y: usize, left: usize, right: usize, color: [u8; 3]) -> usize {
let (mut runs, mut run) = (0, 0);
for x in left..right {
if shot.at(x, y) == color {
run += 1;
} else {
if run >= PILL_RUN_MIN {
runs += 1;
}
run = 0;
}
}
if run >= PILL_RUN_MIN {
runs += 1;
}
runs
}
/// 药丸描边那一段的最小长度:32 高的圆角药丸(半径 16)在顶边/底边留下的直线段约 28~68px,
/// 取 16 足以跨过圆角收缩,又能甩掉侧边那 1px 竖线。
const PILL_RUN_MIN: usize = 16;
/// `y` 这一行在 `[left, right)` 里是否**整行同色**;是则给出那个颜色。
fn uniform_row(shot: &Shot, y: usize, left: usize, right: usize) -> Option<[u8; 3]> {
let color = shot.at(left, y);
(left..right)
.all(|x| shot.at(x, y) == color)
.then_some(color)
}
/// `04-…-compact736.png`(736×760,`ULL_DL_COMPACT_SHOT`)——**Compact 档主版本栏(缺陷 2)**。
///
/// 修前(M123 取证;本文件作者在 736×760 那张图上逐项量过):主版本栏宽 64,`ListItem`
@@ -75,3 +122,124 @@ fn a_compact_tier_shot_draws_an_icon_for_the_first_major_entry() {
64 宽的通栏行说明它又画成文字了"
);
}
/// **M238 ①**:736×760 下六枚 loader chip **整枚**在面板滚动视口里(不是一条 10px 的缝)。
///
/// 修前实测(M238,`before-736x760-*.png`):面板拿 286px、滚动视口只剩 **54px**
/// (head 40 + 间距 4 之后轮到 chips 只剩 10px),六枚 chip 的下边框一个像素都不在屏;
/// 修后(预算按面板内容推导,`rows/tail.rs::panel_budget`):视口 **98px**,chip 描边
/// 上下各一条(y=462 / y=493,高 31px)。
///
/// 抓得住:面板预算退回「死的 300」(视口 54 ⇒ 下边框消失,只剩上边框那一条描边行)、
/// 或 chip 被任何东西裁掉下缘(判据是「**药丸描边**行至少有两条、且相距 ≥ 28」——圆角药丸的
/// 上/下边框各是一排 ≥16px 的多段同色像素,裁掉任何一条都当场红;分隔线虽然同色但是**通栏
/// 一段**,用段数把两者分开)。
/// 抓不住:chip 的文案有没有被**横向**裁掉(要看字形宽度)、六枚到底是不是一行(那是
/// `rails.rs` 的单测)、hover/焦点态。
#[test]
fn a_compact_tier_shot_draws_the_whole_chip_row() {
let Some(path) = feed(COMPACT_SHOT) else {
return;
};
let shot = Shot::load(&path);
let bands = bands(736.0);
let ui = ui();
let left = bands.list.0 + 8;
let right = bands.list.0 + bands.list.1 as usize - 8;
// 未选中的 chip 有 1px `OutlineVariant` 描边(选中项无描边,见 `selection/paint/chip.rs`)
let edge = rgb(ui.color(Role::OutlineVariant));
let strokes: Vec<usize> = (400..560)
.filter(|y| pill_runs(&shot, *y, left, right, edge) >= 2)
.collect();
assert!(
strokes.len() >= 2,
"chip 的描边行只有 {} 条({strokes:?}):六枚 chip 被面板视口裁成一条缝 \
—— 下边框一个像素都不在屏,用户看不出「下面还有加载器 chip」",
strokes.len()
);
let (top, bottom) = (strokes[0], strokes[strokes.len() - 1]);
assert!(
bottom - top >= 28,
"chip 的上下描边相距只有 {}px(y={top}..{bottom}):chip 本体 32 高,\
被裁的话两个描边行会挨在一起",
bottom - top
);
}
/// **M238 ②**:版本菜单的 L2 阴影不许画到滚动视口之外(压暗下面钉死区的实例名)。
///
/// 修前实测(浅色):菜单容器 `(238,238,239)` 底下那条 8px 缝、以及实例名框内的空白带
/// 全被压成 `(231,231,232)`(−7.2%,与 M222/M227 视觉审记的「实例名被压暗 6.9%」同一处),
/// 深色画面布 `(15,21,15)` 被压成 `(14,20,14)`。压暗带的**长度跟布局有关**:修前的旧预算下
/// 是菜单底往下 54px 一整条;只把预算修好、阴影留着时缩到 8px(= blur 6 + offset 2 那圈)。
/// 所以两条修法各管一半,判据按「缝 + 框内都必须纯画布色」写,两种布局都抓得住。
/// 根因在 `iced_tiny_skia` 0.14:quad 阴影用 `draw_pixmap(.., mask: None)` 画
/// (`engine.rs:136-143`),绕过裁切掩码;`iced_wgpu` 那条走整层 scissor(`quad.rs:96-101`),
/// 于是同一棵树在 CPU / GPU 上画出来不一样。修法 = 不在这条滚动链里画阴影(`Menu::flat`)。
///
/// 抓得住:`Menu::flat` 被撤(阴影回来 ⇒ 缝与框内变成混合色)、α 被调大调小(只要非 0 就红)、
/// 或者有人把菜单挪出滚动区又不裁(`uniform_row` 的定位仍会命中实例名框)。
/// 抓不住:**滚动中**阴影会不会跟出来——静态图只有一帧,`pages::harness` 目前没有
/// `scroll_to` 的派发通道(`rows.rs::PANEL_SCROLL_ID` 的注释是「拿到了就能派」,实际没人写),
/// 所以「滚到中部/底部」这三态在本 mission 无法出图,只能靠「阴影根本不画」从机制上排除。
#[test]
fn a_compact_tier_shot_keeps_the_menu_shadow_inside_the_scroll_viewport() {
let Some(path) = feed(COMPACT_SHOT) else {
return;
};
let shot = Shot::load(&path);
let bands = bands(736.0);
let left = bands.list.0 + 8;
let right = bands.list.0 + bands.list.1 as usize - 8;
let canvas = shot.at(bands.list.0 + 2, 12);
// 实例名框的两条通栏 1px 描边:整行同色,且「上 3 行」与「下 3 行」互为同色、又与它自己不同
// ——通栏细线才满足这个形状;菜单块的边界不满足(块内 3 行是选中项底色)。
let hairline = |y: usize| -> bool {
uniform_row(&shot, y, left, right).is_some_and(|color| {
matches!(
(uniform_row(&shot, y - 3, left, right), uniform_row(&shot, y + 3, left, right)),
(Some(above), Some(below)) if above == below && color != above
)
})
};
let hairlines: Vec<usize> = (shot.height / 2..shot.height - 16)
.filter(|y| hairline(*y))
.collect();
assert!(
hairlines.len() >= 2,
"找不到实例名输入框的上下描边(命中 {hairlines:?}):这条断言要靠它定位「钉死区」"
);
let field_top = hairlines[hairlines.len() - 2];
let field_bottom = hairlines[hairlines.len() - 1];
assert!(
field_bottom > field_top + 40,
"实例名框只有 {}px 高(y={field_top}..{field_bottom}):它被挤扁了,先修那一处再看阴影",
field_bottom - field_top + 1
);
// ① 框上沿往上 8px = 「滚动视口 → 钉死区」的缝:必须**纯画布色**
let middle = (left + right) / 2;
for y in field_top - 8..field_top {
for x in [left + 8, middle, right - 16] {
assert_eq!(
shot.at(x, y),
canvas,
"y={y} x={x} 是 {:?} 而不是画布色 {canvas:?}:菜单的 L2 阴影漏出了滚动视口,\
压暗了视口下面那条 8px 的缝(M238 修前实测 −7.2%)",
shot.at(x, y)
);
}
}
// ② 框内标签以下的空白带也不许被压暗(修前那里是 231 而不是 249)
for y in field_top + 36..field_bottom - 8 {
assert_eq!(
shot.at(right - 24, y),
canvas,
"y={y} 的实例名框内是 {:?} 而不是画布色 {canvas:?}:阴影画到了框里面",
shot.at(right - 24, y)
);
}
}