From df0944894f4c3d0cef9a3a4a690421fe3fd56ab4 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 08:28:06 +0800 Subject: [PATCH] =?UTF-8?q?fix(app-iced):=20=E9=9D=A2=E6=9D=BF=E9=A2=84?= =?UTF-8?q?=E7=AE=97=E6=8C=89=E5=86=85=E5=AE=B9=E6=8E=A8=E5=AF=BC=20+=20?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E8=8F=9C=E5=8D=95=E4=B8=8D=E7=94=BB=20L2=20?= =?UTF-8?q?=E9=98=B4=E5=BD=B1=EF=BC=88M238=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两条都在「视口不终止内容」这一族里,修法各一半: ① 三栏档面板预算从死的 `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)。 --- crates/app-iced/src/components/inputs/menu.rs | 27 +-- .../src/components/inputs/menu/shadow.rs | 54 ++++++ .../src/components/inputs/menu/tests.rs | 25 +++ crates/app-iced/src/pages/download.rs | 8 +- crates/app-iced/src/pages/download/options.rs | 2 +- .../src/pages/download/options/model.rs | 14 ++ .../src/pages/download/options/view.rs | 18 +- crates/app-iced/src/pages/download/rails.rs | 17 +- crates/app-iced/src/pages/download/rows.rs | 8 +- .../app-iced/src/pages/download/rows/tail.rs | 180 +++++++++++++++--- .../download/tests/options_shot/compact.rs | 170 ++++++++++++++++- 11 files changed, 454 insertions(+), 69 deletions(-) create mode 100644 crates/app-iced/src/components/inputs/menu/shadow.rs diff --git a/crates/app-iced/src/components/inputs/menu.rs b/crates/app-iced/src/components/inputs/menu.rs index f689f4e..488521c 100644 --- a/crates/app-iced/src/components/inputs/menu.rs +++ b/crates/app-iced/src/components/inputs/menu.rs @@ -19,6 +19,10 @@ //! **容器色不取 `MaterialLayer::Overlay`**:那是 `SurfaceContainerHigh`,比 A1 的 `SurfaceContainer` //! 高一档;`MaterialLayer` 只用来确认「弹出层属于 Overlay 语义」,颜色以 A1 为准。 //! +//! 阴影不是所有用法都要:被放进**滚动视口**(会被裁切)的菜单要用 [`Menu::flat`]—— +//! CPU 后端画阴影时绕过裁切掩码,阴影会漏到视口外压暗下面的内容(M238,理由与实证见 +//! `menu/shadow.rs` 的 `flat` 文档)。 +//! //! ## 分组分隔线 //! //! 复用 [`crate::app::components::divider::full`](一像素线 + `OutlineVariant`),**不自己画线**。 @@ -26,9 +30,7 @@ use iced::widget::text::LineHeight; use iced::widget::{button, column, container, text}; -use iced::{ - Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow, Vector, -}; +use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow}; use unlucky_design::color::Role; use unlucky_design::component_map::Component; @@ -46,6 +48,10 @@ use super::super::interaction::{self, Base, Interaction}; use super::super::layout; use super::geometry::MenuGeometry; +mod shadow; + +use shadow::panel_shadow; + /// 菜单里的一项。 #[derive(Debug, Clone)] pub struct MenuItem<'a, M> { @@ -121,6 +127,7 @@ pub enum Entry<'a, M> { pub struct Menu<'a, M> { entries: Vec>, width: Length, + shadow: bool, } impl<'a, M> Default for Menu<'a, M> { @@ -135,6 +142,7 @@ impl<'a, M> Menu<'a, M> { Self { entries: Vec::new(), width: Length::Fill, + shadow: true, } } @@ -215,7 +223,7 @@ fn item_palette(ui: &Ui, base: Base, state: Interaction, selected: bool) -> Item /// 项的禁用态(38% 内容)与状态层拿同一个 [`Base`] 合成——两边同源,改色不会只改一半(M234)。 pub const PANEL_BASE: Base = Base::of(Role::SurfaceContainer); -/// 菜单弹出层(`SurfaceContainer` + L2 阴影)。 +/// 菜单弹出层(`SurfaceContainer` + L2 阴影;[`Menu::flat`] 时无阴影)。 pub fn menu<'a, M: Clone + 'a>(ui: &'a Ui, menu: Menu<'a, M>) -> Element<'a, M> { let geometry = MenuGeometry::of(ui); let spec = Component::Menu.spec(); @@ -225,7 +233,7 @@ pub fn menu<'a, M: Clone + 'a>(ui: &'a Ui, menu: Menu<'a, M>) -> Element<'a, M> Elevation::Level2, "MenuTokens.ContainerElevation" ); - let shadow_spec = elevation.shadow(); + let shadow = panel_shadow(ui, elevation.shadow(), menu.shadow); let panel = ui.color(PANEL_BASE.role()); let shape = geometry.shape; @@ -246,14 +254,7 @@ pub fn menu<'a, M: Clone + 'a>(ui: &'a Ui, menu: Menu<'a, M>) -> Element<'a, M> radius: ui.radius_dp(shape), ..Border::default() }, - shadow: Shadow { - color: Color { - a: shadow_spec.alpha, - ..ui.color(Role::Shadow) - }, - offset: Vector::new(0.0, shadow_spec.offset_y_px), - blur_radius: shadow_spec.blur_px, - }, + shadow, ..container::Style::default() }) .into() diff --git a/crates/app-iced/src/components/inputs/menu/shadow.rs b/crates/app-iced/src/components/inputs/menu/shadow.rs new file mode 100644 index 0000000..c84bbff --- /dev/null +++ b/crates/app-iced/src/components/inputs/menu/shadow.rs @@ -0,0 +1,54 @@ +//! 菜单「要不要画 L2 阴影」(M238)。 +//! +//! 从 `menu.rs` 拆出来只为守 400 行硬线(`components::gate::every_source_file_stays_under_the_line_limit`): +//! 那边加完「内联列表口径」正好 400 行、零余量。搬移的是**新增**的两小块,没有改动既有逻辑。 + +use iced::{Color, Shadow, Vector}; + +use unlucky_design::color::Role; +use unlucky_design::elevation::ShadowSpec; + +use crate::theme::Ui; + +use super::Menu; + +impl<'a, M> Menu<'a, M> { + /// **内联列表口径**:不画 L2 阴影(容器色 / 圆角 / 项几何照旧)。 + /// + /// 用在「菜单被放进**会被裁切的容器**(滚动视口)」的场合——下载页的加载器版本列表就是 + /// 这一种:它是面板里的一串版本,不是浮在页面上的弹出层。 + /// + /// 为什么必须在这里让步而不是「让它裁掉」:`iced_tiny_skia` 0.14 画 quad 阴影时用 + /// `pixmap.draw_pixmap(.., mask: None)`(`iced_tiny_skia/src/engine.rs:136-143`),**绕过** + /// 了 `clip_mask` ⇒ 阴影照画到视口之外,压暗下面钉死区的实例名(M238 实测:浅色画面布 + /// `(249,249,250)` 被压成 `(231,231,232)`,−7.2%)。`iced_wgpu` 那条路走整层的 scissor + /// (`iced_wgpu/src/quad.rs:96-101`)⇒ 同一棵树在 GPU / CPU 两个后端上**画出来的不一样**。 + /// 不画阴影让两边一致,也把「浮层」语义收回到真正浮起来的地方。 + /// + /// 层级观感:`Component::Menu.spec().elevation` 仍是 **L2**(令牌表不动),真实浮层 + /// (gallery / 将来的下拉)照旧带阴影;只有「内联列表」这一种用法放弃阴影,靠 + /// `SurfaceContainer` 底色 + 4 圆角与画布区分(L1 卡片 → L2 菜单这条阶梯不被削平)。 + pub fn flat(mut self) -> Self { + self.shadow = false; + self + } +} + +/// 面板阴影:`elevated` 为假([`Menu::flat`])时给 `Shadow::default()`——**不是**把 α 调小, +/// 而是让 tiny-skia 的 `shadow.color.a > 0.0` 一判就直接跳过整块阴影绘制。 +/// +/// 单独抽出来是为了让「内联列表不画阴影」这条可断言:`Element` 没有几何/样式内省, +/// 单测只能钉这个纯函数(见 `menu/tests.rs`)。 +pub(super) fn panel_shadow(ui: &Ui, spec: ShadowSpec, elevated: bool) -> Shadow { + if !elevated { + return Shadow::default(); + } + Shadow { + color: Color { + a: spec.alpha, + ..ui.color(Role::Shadow) + }, + offset: Vector::new(0.0, spec.offset_y_px), + blur_radius: spec.blur_px, + } +} diff --git a/crates/app-iced/src/components/inputs/menu/tests.rs b/crates/app-iced/src/components/inputs/menu/tests.rs index e03106f..84c0f33 100644 --- a/crates/app-iced/src/components/inputs/menu/tests.rs +++ b/crates/app-iced/src/components/inputs/menu/tests.rs @@ -121,3 +121,28 @@ fn menu_shapes_build() { let _: Element<'static, u8> = sample(flavor); } } + +/// M238:`flat()`(内联列表口径)**整块阴影不画**,不是「把 α 调小」。 +/// +/// 抓得住:有人把 `flat` 实现成「α × 0.5」或只改 blur(`Shadow{ alpha>0, blur=0 }` 在 tiny-skia 里 +/// 仍会走阴影分支),以及有人把 `menu()` 里那个 `panel_shadow(.., menu.shadow)` 的第三参写死成 +/// `true`(那样 flat 菜单又带上阴影、漏回视口外)。 +/// 抓不住:阴影**画出来**的效果(`Element` 没有样式内省);这条只钉决策函数, +/// 画出来的证据是 736 那张像素图(`tests/options_shot/compact.rs`)。 +#[test] +fn flat_menus_skip_the_shadow_instead_of_toning_it_down() { + let ui = ui(SchemeFlavor::Light); + let spec = Elevation::Level2.shadow(); + + let elevated = panel_shadow(&ui, spec, true); + assert_eq!(elevated.blur_radius, 6.0); + assert_eq!((elevated.offset.x, elevated.offset.y), (0.0, 2.0)); + assert_eq!(elevated.color.a, 0.14, "浮层:令牌给的 α 原样上身"); + + let flat = panel_shadow(&ui, spec, false); + assert_eq!( + (flat.color.a, flat.blur_radius), + (0.0, 0.0), + "内联列表:α 与 blur 都归零(tiny-skia 见 α = 0 直接跳过整块阴影,不是淡一点)" + ); +} diff --git a/crates/app-iced/src/pages/download.rs b/crates/app-iced/src/pages/download.rs index 37fb11d..598176f 100644 --- a/crates/app-iced/src/pages/download.rs +++ b/crates/app-iced/src/pages/download.rs @@ -251,12 +251,14 @@ fn body<'a>( // 三栏不成立(可用宽 < 600):类型与主版本原样退回顶部 chips,列表吃掉整条可用宽。 // 高度分法跟着档位走:chips 档多花两行 chip,面板必须收回自己的预算(M125 缺陷 1)。 // M227:这一档也改成「面板先保预算、列表按整行吃剩下的」——预算比三栏档紧得多 - // (`rows::CHIPS_PANEL_BUDGET` 192 vs `PANEL_BUDGET` 300),清单见 `rows/tail.rs`。 - let share = rows::TailShare::of(rails.tier, &install::panel_state(model)); + // (`rows::CHIPS_PANEL_BUDGET` 192 vs 三栏档的 `rows::panel_budget`),清单见 `rows/tail.rs`。 + let state = install::panel_state(model); + let share = rows::TailShare::of(rails.tier, &state); + let list_height = share.list_height(body_height, rows::menu_items(&state)); column![ rails::stacked_chips(ui, catalog, model), divider::full(ui), - rows::content(ui, catalog, model, share, share.list_height(body_height)), + rows::content(ui, catalog, model, share, list_height), ] .spacing(ui.space(Space::X2)) .width(Length::Fill) diff --git a/crates/app-iced/src/pages/download/options.rs b/crates/app-iced/src/pages/download/options.rs index 940570e..6f3ca8a 100644 --- a/crates/app-iced/src/pages/download/options.rs +++ b/crates/app-iced/src/pages/download/options.rs @@ -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}; diff --git a/crates/app-iced/src/pages/download/options/model.rs b/crates/app-iced/src/pages/download/options/model.rs index 7f36bbd..d9cd8bd 100644 --- a/crates/app-iced/src/pages/download/options/model.rs +++ b/crates/app-iced/src/pages/download/options/model.rs @@ -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, + } + } } diff --git a/crates/app-iced/src/pages/download/options/view.rs b/crates/app-iced/src/pages/download/options/view.rs index 839bec6..14fba7c 100644 --- a/crates/app-iced/src/pages/download/options/view.rs +++ b/crates/app-iced/src/pages/download/options/view.rs @@ -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())); diff --git a/crates/app-iced/src/pages/download/rails.rs b/crates/app-iced/src/pages/download/rails.rs index 7b664e1..353c165 100644 --- a/crates/app-iced/src/pages/download/rails.rs +++ b/crates/app-iced/src/pages/download/rails.rs @@ -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) diff --git a/crates/app-iced/src/pages/download/rows.rs b/crates/app-iced/src/pages/download/rows.rs index 13eeb94..d414a48 100644 --- a/crates/app-iced/src/pages/download/rows.rs +++ b/crates/app-iced/src/pages/download/rows.rs @@ -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 档共用一份)。 /// diff --git a/crates/app-iced/src/pages/download/rows/tail.rs b/crates/app-iced/src/pages/download/rows/tail.rs index 859e8a6..707c694 100644 --- a/crates/app-iced/src/pages/download/rows/tail.rs +++ b/crates/app-iced/src/pages/download/rows/tail.rs @@ -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 { + /// + /// `menu_items` 只有 `Shared` 档用得上(面板预算按版本菜单的行数推,见 [`panel_budget`]); + /// 其余两支忽略它——调用点统一传 [`menu_items`] 的返回值即可。 + pub(crate) fn list_height(self, column_height: f32, menu_items: usize) -> Option { 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)); } } diff --git a/crates/app-iced/src/pages/download/tests/options_shot/compact.rs b/crates/app-iced/src/pages/download/tests/options_shot/compact.rs index 3027ad5..5d3865d 100644 --- a/crates/app-iced/src/pages/download/tests/options_shot/compact.rs +++ b/crates/app-iced/src/pages/download/tests/options_shot/compact.rs @@ -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 = (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 = (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) + ); + } +}