From 822f98022d76e9bf12fef5882b065c22bb8060ca Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 15:48:55 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(app-iced):=20M125=20chips=20=E6=A1=A3?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E4=B8=8D=E8=A2=AB=E9=80=89=E9=A1=B9=E5=8C=BA?= =?UTF-8?q?=E5=8E=8B=E6=88=90=E7=BC=9D=EF=BC=9BCompact=20=E6=A1=A3?= =?UTF-8?q?=E4=B8=BB=E7=89=88=E6=9C=AC=E9=A6=96=E9=A1=B9=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=A3=81=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两条缺陷都是 M123 取证时在真图上拍出来的,塔台另行核过图: 缺陷 1(medium):chips 档(窗口 < 736,可用宽 < 600)里选项区按内容高 ≈316px 钉在列表下面,列表只剩 18px(0.4 行,行高 44)——版本号读不出来、 换不了版本。修法:`rows::TailShare` 两档,chips 档改成「列表与面板各吃一半、 面板自己可滚」(列表拿到 ≈182px = 4 整行,面板顶端那个实心「安装」与五个 loader chip 仍在屏内);三栏三档照旧钉死(760 高下列表还有 3 整行、面板 整块在屏内,两样都对)。判据按档位而不是按高度:页面模型里只有 window_width, 窗口高度没接进页面(边界写进 TailShare 文档与报告)。 缺陷 2(medium):Compact 档主版本栏宽 64,`ListItem` 两侧内边距各 16 ⇒ 文本格 32,首项「All versions」要 ≈84 ⇒ 图上只剩「All v」;中文「全部版本」= 4 全角字 = 64,32 里只剩「全部」且正好顶到裁切边。修法:图标条档的「全部版本」不画文字, 改画与本页第一级那个「全部」同一个网格图标(40×40 Tonal 药丸 + 全称 tooltip); 桶标签(1.21/24w,实测墨迹 26)放得下,照旧画原文。`rails.rs` 那条「64 宽里 1.21 仍完整」的注释改成实测宽度表,并写清它对首项不成立。 顺带:列表与尾部面板之间补上分隔线(三态一致),`rows::content` 与两个装配点 按档位传 `TailShare`。 守门: - `tests/options_shot.rs` 解掉 M123 那条 `#[ignore]`(期望值一个字没改,只加 「列表区 ≥ 3 整行」与「面板仍在屏内、没被底边切」两条); - 新增 `a_compact_tier_shot_draws_an_icon_for_the_first_major_entry`:缺陷 2 的 像素守门(首项是 40×40 居中药丸,不是 64×44 通栏文字行)。复审建议的 「墨迹右边界 ≤ 栏右内边界」在坏图上也是绿的(修前墨迹 216..244,边界 248), 所以改钉形状,理由写在测试文档里; - `tests/rails.rs`:中文与英文都走 Icon 分支、以及 32px 文本格的宽度账 (「全部版本」= 64 > 32 是精确值,「全部」= 32 零余量所以不选短标签); - `tests/view.rs`:档位 → TailShare 的决策面 + 两个装配点都按档位传。 未改产品文案、未改窗口最小宽、未动 M123 的任何断言期望值。 --- crates/app-iced/src/pages/download.rs | 5 +- crates/app-iced/src/pages/download/install.rs | 4 + crates/app-iced/src/pages/download/rails.rs | 114 +++++++++--- crates/app-iced/src/pages/download/rows.rs | 75 +++++++- .../src/pages/download/tests/options_shot.rs | 168 ++++++++++++++++-- .../src/pages/download/tests/rails.rs | 90 +++++++++- .../app-iced/src/pages/download/tests/view.rs | 35 ++++ 7 files changed, 444 insertions(+), 47 deletions(-) diff --git a/crates/app-iced/src/pages/download.rs b/crates/app-iced/src/pages/download.rs index d0e85dd..a928caa 100644 --- a/crates/app-iced/src/pages/download.rs +++ b/crates/app-iced/src/pages/download.rs @@ -223,11 +223,12 @@ fn body<'a>( let content: Element<'a, Message> = if rails.tier.is_three_column() { rails::three_column(ui, catalog, model, rails) } else { - // 三栏不成立(可用宽 < 600):类型与主版本原样退回顶部 chips,列表吃掉整条可用宽 + // 三栏不成立(可用宽 < 600):类型与主版本原样退回顶部 chips,列表吃掉整条可用宽。 + // 高度分法跟着档位走:chips 档多花两行 chip,面板必须收回自己的一半(M125 缺陷 1)。 column![ rails::stacked_chips(ui, catalog, model), divider::full(ui), - rows::content(ui, catalog, model), + rows::content(ui, catalog, model, rows::TailShare::of(rails.tier)), ] .spacing(ui.space(Space::X2)) .width(Length::Fill) diff --git a/crates/app-iced/src/pages/download/install.rs b/crates/app-iced/src/pages/download/install.rs index 93d34c2..ec6e124 100644 --- a/crates/app-iced/src/pages/download/install.rs +++ b/crates/app-iced/src/pages/download/install.rs @@ -9,6 +9,10 @@ //! 列表装的是几十行版本;底部按钮如果跟着滚,用户在列表中间永远看不到「安装」在哪。 //! 所以列表独立滚动(`rows::list`),面板固定占尾部一块高度。 //! +//! **M125 的例外**:chips 档(窗口 < 736,竖栏退回顶部 chip 行)里这块面板太贵——它按内容高 +//! 钉死会把版本列表压成 18px 的缝。那一档改成「列表与面板各吃一半、面板自己可滚」 +//! (`rows::TailShare::Shared`),所以「钉在底部」只对三栏档成立。 +//! //! ## 只有意图,没有乐观回显 //! //! | 控件 | 消息 | app 层落点 | diff --git a/crates/app-iced/src/pages/download/rails.rs b/crates/app-iced/src/pages/download/rails.rs index 0aa3b8d..da9a238 100644 --- a/crates/app-iced/src/pages/download/rails.rs +++ b/crates/app-iced/src/pages/download/rails.rs @@ -79,9 +79,14 @@ pub(super) fn three_column<'a>( rails.major_rail, major_rail(ui, catalog, model, rails.major_rail) ), - container(rows::content(ui, catalog, model)) - .width(Length::Fixed(rails.list)) - .height(Length::Fill), + container(rows::content( + ui, + catalog, + model, + rows::TailShare::of(rails.tier), + )) + .width(Length::Fixed(rails.list)) + .height(Length::Fill), ] .spacing(super::super::layout::rails::RAIL_GAP) .width(Length::Fill) @@ -165,19 +170,21 @@ fn labeled<'a>( press: Message, ) -> Element<'a, Message> { let button = buttons::icon(ui, select_variant(selected), kind, Some(press), None); - tooltip( - button, - container(frame::text_role( - ui, - TypeRole::BodySmall, - false, - label, - ui.color(Role::OnSurface), - )) - .padding(ui.space(Space::X2)) - .style(move |_theme| frame::filled(ui.layer(MaterialLayer::Overlay))), - Position::Right, - ) + tooltip(button, tip_body(ui, label), Position::Right).into() +} + +/// tooltip 的正文盒子(`BodySmall` + `OnSurface` 叠 `Overlay` 层):类型栏的图标项与 +/// 主版本栏图标条档的「全部版本」共用一份文字语言。 +fn tip_body<'a>(ui: &'a Ui, label: &'a str) -> Element<'a, Message> { + container(frame::text_role( + ui, + TypeRole::BodySmall, + false, + label, + ui.color(Role::OnSurface), + )) + .padding(ui.space(Space::X2)) + .style(move |_theme| frame::filled(ui.layer(MaterialLayer::Overlay))) .into() } @@ -255,7 +262,7 @@ fn major_rail<'a>( } for entry in major_entries(catalog, model) { - body = body.push(major_item(ui, entry.label, entry.key, entry.selected)); + body = body.push(major_item(ui, entry, icon_only)); } body.into() @@ -276,6 +283,43 @@ pub(super) struct MajorEntry<'a> { pub selected: bool, } +/// 一项在 iced 里**画成什么样**(纯函数产出,单测直接钉它 —— 两种形状的取舍见 [`major_shape`])。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum MajorShape<'a> { + /// `ListItem` 单行文字(展开档的每一项、图标条档的桶标签) + Text(&'a str), + /// 图标 + tooltip 全称(图标条档的「全部版本」,见 [`major_shape`] 的宽度账) + Icon(&'a str), +} + +/// 图标条档画成什么样:**只有「全部版本」这一项不是文字**。 +/// +/// ## 64 宽的可见文本宽是 **32**,不是 40(M123 取证实测) +/// +/// `ListItem` 两侧内边距各 16(`pad_start` / `pad_end`),所以 64 宽的栏里文本格 +/// = 64 − 32 = **32**,再长就被 `clip(true)` 切掉。`BodyLarge` 16px 下: +/// +/// | 标签 | 墨迹宽 | 32 里放得下? | +/// |---|---|---| +/// | `1.21` | 26(736×760 那张实测) | ✓ 余量 6 | +/// | `24w` | 26(同上) | ✓ 余量 6 | +/// | `All versions` | ≈84(12 字符,按 0.4em 下限也有 77) | ✗ 只画得出「All v」(缺陷 2 的原图) | +/// | `全部版本` | 4 × 16 = **64**(全角 1em,精确值) | ✗ 只画得出「全部」,而且正好顶到裁切边 | +/// +/// 短标签这条退路也不行:中文「全部」= 2 × 16 = **32**,与文本格**一样宽、零余量**, +/// 「裁不裁」取决于字体渲染的小数位——一个查不出、也守不住的边界。所以图标条档的 +/// 「全部版本」**不画文字**:换成与本页第一级那个「全部」同一个网格图标 +/// ([`category_icon`] 的 `Category::All`),全称进 tooltip(宽度不设限,中英文都完整)。 +/// +/// 桶标签本来就是 3~5 字符的短串,两档都照旧画原文。 +pub(super) const fn major_shape(entry: MajorEntry<'_>, icon_only: bool) -> MajorShape<'_> { + if icon_only && entry.key.is_none() { + MajorShape::Icon(entry.label) + } else { + MajorShape::Text(entry.label) + } +} + /// 第二级侧栏要画的全部项:`[全部版本, 1.21, 1.20, …]`,顺序 = 桶的版本序。 /// /// 抽成纯函数是为了让「最后一个桶可点」这件事**可断言**(复审 P2-2 的要求): @@ -303,18 +347,38 @@ pub(super) fn major_entries<'a>(catalog: Catalog, model: &'a Download) -> Vec( ui: &'a Ui, - label: &'a str, - key: Option<&str>, - selected: bool, + entry: MajorEntry<'a>, + icon_only: bool, ) -> Element<'a, Message> { - let mut item = ListItem::new(label, Message::SelectMajor(key.map(str::to_string))); - if selected { - item = item.selected(); + let press = Message::SelectMajor(entry.key.map(str::to_string)); + + match major_shape(entry, icon_only) { + MajorShape::Text(label) => { + let mut item = ListItem::new(label, press); + if entry.selected { + item = item.selected(); + } + list_item(ui, item) + } + // 图标条档的「全部版本」:图标(与本页第一级的「全部」同一个网格)+ 全称 tooltip。 + // 高度对齐 `ListItem` 的行高,两档的行节奏才一样。 + MajorShape::Icon(full) => container(labeled( + ui, + full, + category_icon(Category::All), + entry.selected, + press, + )) + .width(Length::Fill) + .height(Length::Fixed(ui.metrics().row_height)) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .into(), } - list_item(ui, item) } /// 顶部 chips(可用宽 < 600 时三栏不成立:类型退回 Primary tabs,主版本变成 Secondary chips 行)。 diff --git a/crates/app-iced/src/pages/download/rows.rs b/crates/app-iced/src/pages/download/rows.rs index 31f6bc2..531ce92 100644 --- a/crates/app-iced/src/pages/download/rows.rs +++ b/crates/app-iced/src/pages/download/rows.rs @@ -17,6 +17,24 @@ //! 折行的唯一可能来源是定宽格子挤爆容器——那条由列表列下限 424 挡住(`layout/rails/tests.rs` //! 的扫描断言)。 //! +//! ## 尾巴不能把列表压成缝(M125 缺陷 1) +//! +//! 这一列是 `column![搜索, 分隔线, 计数头, 列表(Fill), 尾巴]`:`body` 吃剩下的高度、`tail` +//! 按内容高钉死。iced 的 `Column` 不会把溢出交给滚动,所以**尾巴一高,列表就被压成一条缝**—— +//! M123 在 700×760 拍的 `02-options-fabric-en-light-chips700.png` 里,8 行版本只剩 **18px** +//! 可视高(行高 44),版本号一个字都读不出来,用户想换版本点不到。 +//! +//! 病根是 chips 档比三栏档多花两行 chip(tabs 48 + chips 48 + 两处间距),而选项区那一块 +//! 固定 ≈316px(版本行 40 + 标题 16 + 五个 loader chip 34 + 标题 16 + 菜单 112 + 实例名 56)。 +//! 同样的 760 高窗口里:三栏档列表还剩 158px(3 整行),chips 档只剩 18px。 +//! +//! 修法是 [`TailShare`] 两档:三栏档照旧钉死(面板整块在屏内),chips 档改成两半各 `Fill` +//! ——列表拿到 ≈182px(**4 整行**),面板拿到同样一半、**自己可滚**(顶端那个实心「安装」 +//! 按钮与五个 loader chip 仍在屏内,实例名那一格滚一下就到)。 +//! +//! 这条判据按**档位**而不是按高度:页面的模型里只有 `window_width`,窗口高度没接进页面, +//! 所以「760 高到底够不够」在运行时是未知量(边界写在 [`TailShare`] 的文档里)。 +//! //! ## 搜索的三条口径 //! //! | 情形 | 画什么 | 为什么 | @@ -50,15 +68,20 @@ use crate::theme::Ui; use super::super::frame; use super::super::layout::rails::{LIST_DATE_WIDTH, LIST_KIND_WIDTH, ROW_GAP, ROW_ICON_BUTTON}; +use super::super::layout::VersionRailTier; use super::super::Message; use super::model::{Download, VersionRow}; use super::{install, states}; /// 列表列(三栏档与 chips 档共用一份)。 +/// +/// `share` 决定「列表」与「尾部面板」怎么分这块高度:三栏档钉死面板、chips 档各吃一半 +/// (见 [`TailShare`] 与模块文档「尾巴不能把列表压成缝」)。 pub(super) fn content<'a>( ui: &'a Ui, catalog: Catalog, model: &'a Download, + share: TailShare, ) -> Element<'a, Message> { let visible = visible(model); @@ -72,13 +95,23 @@ pub(super) fn content<'a>( list(ui, catalog, model, &visible) }; - let tail: Element<'a, Message> = if model.install.is_some() { - column![divider::full(ui), install::panel(ui, catalog, model)] - .spacing(ui.space(Space::X3)) - .width(Length::Fill) - .into() - } else { + // 列表与尾部面板之间**总是**有一条分隔线(三态一致):M123 的取证图里「选好了版本」 + // 那一态原先没有它,版本行与选项区第一行(版本号 + 安装按钮)读起来像同一块内容。 + let tail: Element<'a, Message> = column![ + divider::full(ui), install::panel(ui, catalog, model) + ] + .spacing(ui.space(Space::X3)) + .width(Length::Fill) + .into(); + + // chips 档:面板收回自己的一半、内部可滚(它原先按内容高钉死,把列表压成 18px 的缝)。 + let tail: Element<'a, Message> = match share { + TailShare::Pinned => tail, + TailShare::Shared => scrollable(tail) + .height(Length::Fill) + .style(frame::scroll_style(ui)) + .into(), }; column![ @@ -94,6 +127,36 @@ pub(super) fn content<'a>( .into() } +/// 列表与尾部面板怎么分这块高度(M125 缺陷 1 的修法)。 +/// +/// | 档 | 分法 | 为什么 | +/// |---|---|---| +/// | `Wide` / `Narrow` / `Compact` | [`TailShare::Pinned`]:面板按内容高钉在底部,列表吃剩下的 | 竖栏档在 760 高的窗口里列表还剩 158px(3 整行),面板整块在屏内——两样都对 | +/// | `Chips` | [`TailShare::Shared`]:两半各 `Fill`,面板自己可滚 | chips 档比三栏档多花两行 chip(tabs 48 + chips 48 + 间距),列表只剩 **18px**(M123 实测 0.4 行),面板 316px 全在屏内也没用——版本号读不出来就换不了版本 | +/// +/// 判据是**档位**不是高度:页面的模型里只有 `window_width`(`pages/model.rs`),窗口高度 +/// 没有接进页面(要接得改 `pages/mod.rs` 的入口签名,超出本页的边界)。所以这里按「哪一档 +/// 的竖排 chrome 会把列表压没」来分,并在报告里登记这条判据的边界。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum TailShare { + /// 面板钉在底部(三栏档)。 + Pinned, + /// 面板与列表各吃一半(chips 档)。 + Shared, +} + +impl TailShare { + /// 档位 → 分高方式(纯函数,单测直接钉它)。 + pub(super) const fn of(tier: VersionRailTier) -> Self { + match tier { + VersionRailTier::Chips => Self::Shared, + VersionRailTier::Wide | VersionRailTier::Narrow | VersionRailTier::Compact => { + Self::Pinned + } + } + } +} + /// 搜索条 + 清空按钮 + 命中数(三者一行;命中数只在有查询词时出现)。 pub(super) fn search_row<'a>( ui: &'a Ui, diff --git a/crates/app-iced/src/pages/download/tests/options_shot.rs b/crates/app-iced/src/pages/download/tests/options_shot.rs index cdb6006..590fe58 100644 --- a/crates/app-iced/src/pages/download/tests/options_shot.rs +++ b/crates/app-iced/src/pages/download/tests/options_shot.rs @@ -37,8 +37,10 @@ const FAMILIES: Families = Families { /// 1180 宽那张(`01-options-fabric-en-light.png`)。 const WIDE_SHOT: &str = "ULL_DL_OPTIONS_SHOT"; -/// 700 宽那张(`03-options-fabric-en-light-chips700.png`)——只在 `--ignored` 那条里用。 +/// 700 宽那张(`02-options-fabric-en-light-chips700.png`)——chips 档回归守门用。 const CHIPS_SHOT: &str = "ULL_DL_CHIPS_SHOT"; +/// 736 宽那张(`04-options-fabric-en-light-compact736.png`)——Compact 档主版本栏用。 +const COMPACT_SHOT: &str = "ULL_DL_COMPACT_SHOT"; fn ui() -> Ui { Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES) @@ -123,6 +125,44 @@ impl Shot { }) }) } + + /// `y` 这一行上、含 `x` 的那段**连续非底色**区:`(起点, 长度)`。 + /// + /// 与 [`Shot::run_at`] 的区别:颜色可以变(药丸底色 + 里面的图标墨迹算同一块)。 + fn fill_run( + &self, + y: usize, + left: usize, + right: usize, + x: usize, + background: [u8; 3], + ) -> (usize, usize) { + let filled = |x: usize| self.at(x, y) != background; + let mut start = x; + while start > left && filled(start - 1) { + start -= 1; + } + let mut end = x; + while end + 1 < right && filled(end + 1) { + end += 1; + } + (start, end - start + 1) + } + + /// 列表列里、`from` 起的**第一条薄通栏横线**的 y(`divider::full` 画的那条 1px 线)。 + /// + /// 用它量「版本列表到哪儿为止」:尾部面板上沿有一条通栏分隔线,它上面的高度就是列表拿到的。 + /// 只认**薄**线(上下两行都与它不同色)——选中行的底色带也是通栏的,那是 44px 的块,不是线。 + fn first_rule_y(&self, left: usize, right: usize, from: usize, background: [u8; 3]) -> Option { + let thin = |y: usize| { + let color = self.at(left + 2, y); + color != background + && (left + 2..right.saturating_sub(2)).all(|x| self.at(x, y) == color) + && (y == 0 || self.at(left + 2, y - 1) != color) + && (y + 1 >= self.height || self.at(left + 2, y + 1) != color) + }; + (from..self.height).find(|y| thin(*y)) + } } /// 三栏的 x 区间,全部从**产品自己的裁决函数**推出来(不是手抄的像素坐标)。 @@ -258,18 +298,26 @@ fn an_options_shot_shows_both_rails_and_the_whole_panel() { ); } -/// `02-options-fabric-en-light-chips700.png`(700×760)——**这条现在是红的**(M123 的缺陷登记)。 +/// `02-…-chips700.png`(700×760,`ULL_DL_CHIPS_SHOT`)——**M125 缺陷 1 的回归守门**。 /// -/// 现状(2026-11-05,tiny-skia,700×760,`ULL_DL_DEMO=options-fabric`):Chips 档多出两行 chip, -/// 选项区又整块钉在列表下面,于是列表列只剩 ~22px 的缝,选中行的底色带实测 19px(行高 44)。 -/// 断言写的是**该有的**结果;修好之后删掉 `#[ignore]` 就是它的回归守门。 +/// 修前(M123 取证,2026-11-05,tiny-skia,700×760,`ULL_DL_DEMO=options-fabric`): +/// chips 档多出两行 chip,选项区又整块钉在列表下面(316px),于是列表列只剩一条 **18px** 的缝, +/// 选中行的底色带实测 19px(行高 44)——版本号一个字读不出来,用户换不了版本。 +/// 修法见 `rows::TailShare`:chips 档改成「列表与面板各吃一半,面板自己可滚」。 /// /// ```text /// ULL_DL_CHIPS_SHOT=02-options-fabric-en-light-chips700.png \ -/// cargo test -p unlucky-app-iced --lib options_shot -- --ignored +/// cargo test -p unlucky-app-iced --lib options_shot /// ``` +/// +/// 抓得住:①首行版本行的底色带 = 44(整行在屏);②**至少 3 整行**读得出来(数墨迹条数); +/// ③选项面板仍在屏内(列表列最后一行墨迹明显低于版本行区,且没贴到底边)—— +/// 「能换版本」与「能看见选项」是这条断言的成对验收。 +/// +/// 抓不住:文本有没有被**横向**裁掉(那要看字形宽度,见 `tests/rails.rs` 的标签断言与 +/// 报告里的量测表)、hover/tooltip 层(静态图拍不到)、面板内部滚动到底后长什么样 +/// (静态图只有一帧,只能拍到 scroll offset 0)。 #[test] -#[ignore = "M123 缺陷:Chips 档(窗口 <736)列表被压成 ~19px,见报告「发现的缺陷」第 1 条"] fn a_chips_tier_shot_keeps_a_whole_version_row_readable() { let Some(path) = shot_path(CHIPS_SHOT) else { eprintln!("[options-shot] SKIPPED:未设置 {CHIPS_SHOT}。"); @@ -278,12 +326,110 @@ fn a_chips_tier_shot_keeps_a_whole_version_row_readable() { let shot = Shot::load(&path); let bands = bands(700.0); let canvas = shot.at(bands.list.0 + 2, 12); - let (_, row_height) = shot + let metrics = ui().metrics(); + + // ① 首行整行 44:这是 M123 原本写的那条(修好之后解 ignore,不许改期望值迁就现状) + let (row_top, row_height) = shot .first_filled_band(bands.list.0 + 60, 360, 440, canvas) .expect("列表列里应当有版本行"); assert_eq!( - row_height as f32, - ui().metrics().row_height, - "Chips 档的版本行被压成 {row_height}px(应为 44)" + row_height as f32, metrics.row_height, + "Chips 档的版本行被压成 {row_height}px(应为 {})", + metrics.row_height + ); + + // ② 版本列表那一块至少 3 整行高:尾部面板上沿的通栏分隔线就是列表的下界 + let right = bands.list.0 + bands.list.1 as usize - 2; + let rule = shot + .first_rule_y(bands.list.0, right, row_top, canvas) + .expect("找不到尾部面板上沿的分隔线(列表区的下界量不出来)"); + let rows_px = rule.saturating_sub(row_top) as f32; + assert!( + rows_px >= 3.0 * metrics.row_height, + "Chips 档的列表区只有 {rows_px}px(y={row_top}..{rule}),不足 3 整行({}):\ + 尾部面板把列表压扁了", + 3.0 * metrics.row_height + ); + + // ③ 面板在屏内:最下面一行墨迹明显低于版本行区(不是列表自己),也没贴到窗口底边 + let last = shot + .last_ink_row( + bands.list.0, + bands.list.0 + bands.list.1 as usize - 2, + canvas, + ) + .expect("列表列里应当有内容"); + assert!( + last > row_top + 300, + "选项面板没画出来:最后一行墨迹在 y={last},版本行从 y={row_top} 起" + ); + assert!( + last + 8 < shot.height, + "面板被窗口底边切掉:最后一行墨迹在 y={last}(图高 {})", + shot.height + ); +} + +/// `04-…-compact736.png`(736×760,`ULL_DL_COMPACT_SHOT`)——**Compact 档主版本栏(缺陷 2)**。 +/// +/// 修前(M123 取证):主版本栏宽 64,`ListItem` 两侧内边距各 16 ⇒ 文本格 32, +/// 而首项「All versions」要 ≈84 ⇒ 图上只剩「**All v**」。中文更糟:「全部版本」= 4 全角字 = 64, +/// 32 里只剩「全部」而且正好顶到裁切边。 +/// +/// 修后:图标条档的「全部版本」**不画文字**(`rails::major_shape` 的 `Icon` 分支)—— +/// 画成与本页第一级那个「全部」同一个网格图标(40×40 `Tonal` 药丸 + 全称 tooltip)。 +/// +/// 抓得住:①首项是 **40×40 的图标药丸**(不是 64 宽 × 44 高的通栏文字行)——谁把它改回 +/// 「画文字」(全称或短标签都会是通栏 44 行)这条立刻红;②药丸在 64 栏里**居中**(左右各 12)。 +/// 抓不住:图标字形对不对、tooltip 长什么样(静态图拍不到 tooltip;字形要看图)。 +/// +/// ## 为什么不用「文字墨迹右边界 ≤ 栏右内边界」这条(复审建议的那条) +/// +/// 它**在坏图上也是绿的**:修前那张图实测首项文字墨迹 x=216..244,而栏右内边界是 +/// `200 + 64 − 16 = 248` ⇒ 244 ≤ 248 成立——「All **v**」明明被裁掉了,断言却过。 +/// 原因是 `ListItem` 的 `clip(true)` 让墨迹**永远**停在裁切边之前,边界的比较量不出「被裁」。 +/// 所以这里改钉**形状**:文字行是 64×44 的通栏带,图标按钮是 40×40 的居中药丸,两者像素可分。 +/// +/// **中文为什么没有同档图**:本仓的截图通道 `pages::demo_model` 把 `Model::new(..)` 的 +/// `lang` 写死成 `Lang::En`(`pages/model.rs:120`),`ULL_LANG` 只作用到 app 的 `App`, +/// 拍不出中文页面 —— 所以中文的结论走「数据面断言 + 算术」: +/// `tests/rails.rs` 的 `only_the_first_major_entry_switches_to_an_icon_in_the_icon_rail` +/// (中文与英文走**同一条**图标分支,键判据 `entry.key.is_none()` 与语言无关)与 +/// `the_icon_rail_text_box_is_32_and_only_bucket_labels_fit`(「全部版本」= 64 > 32)。 +#[test] +fn a_compact_tier_shot_draws_an_icon_for_the_first_major_entry() { + let Some(path) = shot_path(COMPACT_SHOT) else { + eprintln!("[options-shot] SKIPPED:未设置 {COMPACT_SHOT}。"); + return; + }; + let shot = Shot::load(&path); + assert_eq!(shot.width, 736, "Compact 档那张必须是 736 宽"); + + let bands = bands(736.0); + let ui = ui(); + let rail_left = bands.major_rail.0; + let rail_right = rail_left + bands.major_rail.1 as usize; + let center = rail_left + bands.major_rail.1 as usize / 2; + // 栏的底色(`MaterialLayer::Card`):取栏内空白处,不取窗口画布 + let card = shot.at(rail_left + 2, 500); + + // ① 首项的竖向量:40(图标按钮的边长)——文字行会是 44 + let (top, height) = shot + .first_filled_band(center, 156, 300, card) + .expect("主版本栏里应当有第一项"); + assert_eq!( + height as f32, + ui.metrics().control_height, + "Compact 档主版本栏首项应当是 40 高的图标按钮(y={top}),实测 {height}px —— \ + 回到「ListItem 文字行」就是 44" + ); + + // ② 横向:40 宽的药丸,在 64 栏里居中(左右各 12) + let (start, len) = shot.fill_run(top + height / 2, rail_left, rail_right, center, card); + assert_eq!( + (start, len as f32), + (rail_left + 12, ui.metrics().control_height), + "首项应当是 40 宽的居中药丸(栏 {rail_left}..{rail_right}),实测 x={start} 宽 {len} —— \ + 64 宽的通栏行说明它又画成文字了" ); } diff --git a/crates/app-iced/src/pages/download/tests/rails.rs b/crates/app-iced/src/pages/download/tests/rails.rs index b575d76..ff469e4 100644 --- a/crates/app-iced/src/pages/download/tests/rails.rs +++ b/crates/app-iced/src/pages/download/tests/rails.rs @@ -2,18 +2,21 @@ //! //! 从 `download/tests.rs` 拆出来:那边已经贴着 400 行硬线。 -use unlucky_i18n::Lang; +use unlucky_i18n::{Catalog, Lang}; use unlucky_presenter::dto::{AppEvent, CoreEvent, VersionKind, VersionSummary}; use unlucky_presenter::state::AppState; +use crate::app::components::navigation::geometry::{typography_headline, ListItemGeometry}; use crate::pages::download::options::InstallDraft; -use crate::pages::download::rails::major_entries; +use crate::pages::download::rails::{major_entries, major_shape, MajorShape}; use crate::pages::download::{Category, Download}; use crate::pages::layout::{ available_width, frame_metrics, measure_cap_px, PaneKit, VersionRailTier, VersionRails, - RAIL_GAP, + RAIL_GAP, RAIL_ICON_WIDTH, }; +use super::ui; + /// 三栏要吃掉整条可用宽(不是 424 的居中正文列)——三个宽度档各取一个采样点。 /// /// 抓得住:有人把第三栏写成 `Length::Fill`(那会让三列宽度之和**超过**可用宽、右侧被挤出屏)。 @@ -175,3 +178,84 @@ fn extra_width_goes_to_the_page_block_not_to_the_prose_column() { "两个量要分得开:块宽 1044 比散文列 424 宽出 620" ); } + +// ------------------------------------ 图标条档(64 宽)的主版本栏画什么(M125 缺陷 2) + +/// 一份只有「两个桶」的模型(`1.21` / `1.20`),给标签相关的两条断言当夹具。 +fn bucket_model(catalog: Catalog) -> Download { + let versions: Vec = ["1.21.9", "1.20.4"] + .iter() + .map(|id| VersionSummary { + id: (*id).to_string(), + kind: VersionKind::Release, + released: "2026-09-20".to_string(), + installed: false, + }) + .collect(); + let mut state = AppState::new(); + state.apply(AppEvent::Core(CoreEvent::VersionListChanged(versions))); + Download::from_snapshot(&state, Category::All, &InstallDraft::default(), catalog) +} + +/// **图标条档里只有首项换成图标**,桶标签照旧画文字——两种语言走同一条分支。 +/// +/// 抓得住:谁把首项改回文字(中英文各一条断言,英文会回到「All v」,中文回到只画得出两字); +/// 或者把桶标签也一起换成图标(最后两条断言)。 +/// 抓不住:图标画得对不对称(像素在 `options_shot` 的紧凑档断言与报告里的图)。 +#[test] +fn only_the_first_major_entry_switches_to_an_icon_in_the_icon_rail() { + for (lang, full) in [(Lang::En, "All versions"), (Lang::ZhCn, "全部版本")] { + let catalog = lang.catalog(); + let model = bucket_model(catalog); + let entries = major_entries(catalog, &model); + + let first = entries[0]; + assert_eq!(first.key, None, "{lang:?}:第一项是「全部版本」"); + assert_eq!(first.label, full, "{lang:?}:数据层仍带全称(chips 档要它)"); + assert_eq!( + major_shape(first, true), + MajorShape::Icon(full), + "{lang:?}:图标条档的首项不画文字,全称只进 tooltip" + ); + assert_eq!( + major_shape(first, false), + MajorShape::Text(full), + "{lang:?}:展开档(140)照旧画全称" + ); + + let bucket = entries[1]; + assert_eq!(bucket.key, Some("1.21"), "{lang:?}:第二条是第一个主版本桶"); + assert_eq!(major_shape(bucket, true), MajorShape::Text("1.21")); + assert_eq!(major_shape(bucket, false), MajorShape::Text("1.21")); + } +} + +/// **宽度账**(算术,中英文各算一遍):32px 的文本格里首项的全称放不下、桶标签放得下。 +/// +/// 这条是「为什么不画文字」的可核证依据,也是**中文结论**的那一半(另一半是上面那条 +/// 「中文走图标分支」):中文全角字形 1em ⇒ 宽度是精确值,不是估计。 +#[test] +fn the_icon_rail_text_box_is_32_and_only_bucket_labels_fit() { + let ui = ui(); + let geometry = ListItemGeometry::of(&ui); + let text_box = RAIL_ICON_WIDTH - geometry.pad_start - geometry.pad_end; + assert_eq!(text_box, 32.0, "64 宽的栏 − 2×16 内边距"); + + let em = typography_headline().style().size_px; + assert_eq!(em, 16.0, "ListItem 主文本是 `BodyLarge` 16px"); + + // 中文:全角字形 = 1em ⇒ 「全部」= 32(与文本格同宽 ⇒ 零余量,所以短标签这条退路也不走) + assert_eq!(2.0 * em, text_box, "「全部」正好占满 32:零余量"); + assert!(4.0 * em > text_box, "「全部版本」= 64 > 32:画不出来"); + + // 英文:按 0.4em 的**下限**步进(`BodyLarge` 实测 0.43233em,这里取更保守的下限) + assert!( + 0.4 * em * 12.0 > text_box, + "「All versions」≥ 76.8 > 32:画不出来(改前图上就是「All v」)" + ); + // 桶标签 4 字符:下限步进 25.6 ≤ 32(736 那张图实测墨迹 26,余量 6) + assert!( + 0.4 * em * 4.0 <= text_box, + "「1.21」/「24w」≤ 32:放得下,照旧画文字" + ); +} diff --git a/crates/app-iced/src/pages/download/tests/view.rs b/crates/app-iced/src/pages/download/tests/view.rs index e0e5c3e..ecb2410 100644 --- a/crates/app-iced/src/pages/download/tests/view.rs +++ b/crates/app-iced/src/pages/download/tests/view.rs @@ -145,3 +145,38 @@ fn the_demo_data_is_sane() { .all(|row| row.supporting.contains("released"))); assert!(demo.rows.iter().all(|row| !row.supporting.contains('版'))); } + +/// 列表与尾部面板怎么分高,跟着**档位**走(M125 缺陷 1 的决策面)。 +/// +/// 修前:面板一律按内容高钉在底部,700×760 的 chips 档里列表只剩 18px(0.4 行)。 +/// 修后:chips 档两半各 `Fill`(面板自己可滚),三栏三档照旧钉死(760 高下列表还有 3 整行、 +/// 面板整块在屏内,两样都对)。 +/// +/// 抓得住:有人把 chips 档改回 `Pinned`(700 下的像素守门 `a_chips_tier_shot_…` 立刻红); +/// 或者把三栏档也改成 `Shared`(1180 下选项面板被切成一半,`an_options_shot_…` 会红)。 +/// 抓不住:**实际分到多少像素**(`iced` 的 `Element` 没有几何内省,那只能出图量, +/// 见 `options_shot` 的像素断言与报告里的数值表)。 +#[test] +fn the_chips_tier_shares_the_height_and_the_rails_tiers_pin_it() { + use crate::pages::download::rows::TailShare; + use crate::pages::layout::VersionRailTier; + + assert_eq!(TailShare::of(VersionRailTier::Chips), TailShare::Shared); + for tier in [ + VersionRailTier::Wide, + VersionRailTier::Narrow, + VersionRailTier::Compact, + ] { + assert_eq!(TailShare::of(tier), TailShare::Pinned, "{tier:?}"); + } + + // 两个装配点(三栏装配 / chips 分支)都**按档位**传,不许在某一处写死一个字面量 + let chips_branch = include_str!("../../download.rs"); + let three_column = include_str!("../rails.rs"); + assert_eq!( + chips_branch.matches("TailShare::of(rails.tier)").count() + + three_column.matches("TailShare::of(rails.tier)").count(), + 2, + "三栏装配与 chips 分支都要按档位传 TailShare" + ); +} From 70dea995b7ef27de0cb878ed84f10e76bd0dcff7 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 16:01:03 +0800 Subject: [PATCH 2/4] =?UTF-8?q?refactor(app-iced):=20M125=20=E4=B8=BB?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E6=A0=8F=E4=B8=8E=E9=80=89=E9=A1=B9=E5=8C=BA?= =?UTF-8?q?=E5=83=8F=E7=B4=A0=E5=AE=88=E9=97=A8=E5=90=84=E6=8B=86=E4=B8=80?= =?UTF-8?q?=E5=B1=82=EF=BC=88=E5=AE=88=20400=20=E8=A1=8C=E7=BA=A2=E7=BA=BF?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两处拆分,只为过 `gate::every_source_file_stays_under_the_line_limit`: - `download/rails.rs` 435 行 → `rails.rs` 311 + `rails/major.rs` 157:主版本桶那一栏 (`major_rail` / `major_entries` / `major_shape` / `MajorEntry` / `MajorShape`)搬进子模块, `rail_title` 留在父模块(两条栏共用)。`Key::VersionMajorTitle` 的落点跟着搬, `tests/i18n.rs` 的死 key 扫描表补上 `rails/major.rs`(漏扫会把活 key 判成死 key)。 - `tests/options_shot.rs` 475 行 → 345 + `options_shot/chips.rs` 75 + `options_shot/compact.rs` 67。 顺带修 `options_shot::bands()` 的一处既有缺口:它按三栏算列表列左缘(112+0+24+0+24 = 160), chips 档没有侧栏时这个左缘是错的,右缘跟着冲出画面 22px——M125 的 chips 守门正好踩到 「扫描越界读到下一行像素」,`first_rule_y` 因此恒返回 None。现在 chips 档直接取页左缘 (112),三栏三档的数值一个不变(M123 那些 140/140、缝 24 的断言照旧)。 守门实跑(本机,tiny-skia): - 解 ignore 的 `chips::a_chips_tier_shot_…` 在修后图绿、**修前图红**(实测 18px ≠ 44); - `compact::a_compact_tier_shot_…` 修后图绿、修前图红(实测 44px 文字行 ≠ 40px 图标按钮); - chips 那条换深色图同样绿(断言不依赖主题色)。 --- crates/app-iced/src/pages/download/rails.rs | 174 +++------------- .../src/pages/download/rails/major.rs | 162 +++++++++++++++ crates/app-iced/src/pages/download/rows.rs | 11 +- .../app-iced/src/pages/download/tests/i18n.rs | 4 +- .../src/pages/download/tests/options_shot.rs | 193 +++++------------- .../download/tests/options_shot/chips.rs | 75 +++++++ .../download/tests/options_shot/compact.rs | 67 ++++++ .../src/pages/download/tests/rails.rs | 5 +- 8 files changed, 394 insertions(+), 297 deletions(-) create mode 100644 crates/app-iced/src/pages/download/rails/major.rs create mode 100644 crates/app-iced/src/pages/download/tests/options_shot/chips.rs create mode 100644 crates/app-iced/src/pages/download/tests/options_shot/compact.rs diff --git a/crates/app-iced/src/pages/download/rails.rs b/crates/app-iced/src/pages/download/rails.rs index da9a238..518ab42 100644 --- a/crates/app-iced/src/pages/download/rails.rs +++ b/crates/app-iced/src/pages/download/rails.rs @@ -46,7 +46,15 @@ use super::super::frame; use super::super::layout::{VersionRails, RAIL_ICON_WIDTH}; use super::super::Message; use super::model::{Category, Download}; -use super::{group, rows}; +use super::rows; + +pub(super) mod major; + +pub(super) use major::{major_entries, major_rail}; + +// 只有测试要从这里拿「一项画成什么样」的判定(lib 里由 `major_item` 内部消费) +#[cfg(test)] +pub(super) use major::{major_shape, MajorShape}; /// 类型栏里一项的图标。 /// @@ -197,22 +205,6 @@ fn select_variant(selected: bool) -> IconVariant { } } -/// 折叠 / 展开类型栏的图标按钮(`Sidebar` 字形;折叠中 = `Tonal`)。 -fn fold_action<'a>(ui: &'a Ui, model: &Download, icon_only: bool) -> Element<'a, Message> { - // 已经被宽度压成图标条时不再给开关:那时点它也不会变宽(档位说了算), - // 一个按了没反应的按钮正是 D30 判 p1 的那种假控件。 - if icon_only && !model.type_rail_folded { - return frame::gap(ui.metrics().nav_item_gap); - } - buttons::icon( - ui, - select_variant(model.type_rail_folded), - IconKind::Sidebar, - Some(Message::ToggleTypeRail), - None, - ) -} - /// 栏标题(`LabelMedium` + `OnSurfaceVariant`,与列表计数头同一档字级)。 /// /// 高度固定成 `control_height`(40 = 图标按钮的边长):两条栏的标题必须**横向对齐**, @@ -243,143 +235,25 @@ fn rail_title<'a>( .into() } -/// 第二栏:主版本桶(第一项永远是「全部版本」;没有主版本概念的版本不单独占一项)。 -fn major_rail<'a>( - ui: &'a Ui, - catalog: Catalog, - model: &'a Download, - width: f32, -) -> Element<'a, Message> { - let icon_only = width <= RAIL_ICON_WIDTH; - - let mut body = column![].spacing(ui.space(Space::X1)).width(Length::Fill); - if !icon_only { - body = body.push(rail_title( - ui, - catalog.text(Key::VersionMajorTitle), - frame::gap(ui.metrics().nav_item_gap), - )); +/// 折叠 / 展开类型栏的图标按钮(`Sidebar` 字形;折叠中 = `Tonal`)。 +fn fold_action<'a>(ui: &'a Ui, model: &Download, icon_only: bool) -> Element<'a, Message> { + // 已经被宽度压成图标条时不再给开关:那时点它也不会变宽(档位说了算), + // 一个按了没反应的按钮正是 D30 判 p1 的那种假控件。 + if icon_only && !model.type_rail_folded { + return frame::gap(ui.metrics().nav_item_gap); } - - for entry in major_entries(catalog, model) { - body = body.push(major_item(ui, entry, icon_only)); - } - - body.into() + buttons::icon( + ui, + select_variant(model.type_rail_folded), + IconKind::Sidebar, + Some(Message::ToggleTypeRail), + None, + ) } -/// 第二级侧栏的一项(「全部版本」与每个主版本桶共用一套结构,好让**两条渲染路径** -/// ——竖栏与 chips 行 —— 都用同一份数据,测试也只钉这一份)。 +/// 栏标题(`LabelMedium` + `OnSurfaceVariant`,与列表计数头同一档字级)。 /// -/// 字段全部**借**模型里的桶标签与目录里的静态串:这两条渲染路径都要把标签塞进 iced 控件树, -/// 借本地的 `String` 会让整棵树的生命周期塌到函数作用域里(E0515)。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) struct MajorEntry<'a> { - /// 选中它要发的键(`None` = 「全部版本」) - pub key: Option<&'a str>, - /// 画出来的标签 - pub label: &'a str, - /// 此刻是否选中(**按生效值算**:搜索期间恒为「全部」,见 `group::bucket_selected`) - pub selected: bool, -} - -/// 一项在 iced 里**画成什么样**(纯函数产出,单测直接钉它 —— 两种形状的取舍见 [`major_shape`])。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum MajorShape<'a> { - /// `ListItem` 单行文字(展开档的每一项、图标条档的桶标签) - Text(&'a str), - /// 图标 + tooltip 全称(图标条档的「全部版本」,见 [`major_shape`] 的宽度账) - Icon(&'a str), -} - -/// 图标条档画成什么样:**只有「全部版本」这一项不是文字**。 -/// -/// ## 64 宽的可见文本宽是 **32**,不是 40(M123 取证实测) -/// -/// `ListItem` 两侧内边距各 16(`pad_start` / `pad_end`),所以 64 宽的栏里文本格 -/// = 64 − 32 = **32**,再长就被 `clip(true)` 切掉。`BodyLarge` 16px 下: -/// -/// | 标签 | 墨迹宽 | 32 里放得下? | -/// |---|---|---| -/// | `1.21` | 26(736×760 那张实测) | ✓ 余量 6 | -/// | `24w` | 26(同上) | ✓ 余量 6 | -/// | `All versions` | ≈84(12 字符,按 0.4em 下限也有 77) | ✗ 只画得出「All v」(缺陷 2 的原图) | -/// | `全部版本` | 4 × 16 = **64**(全角 1em,精确值) | ✗ 只画得出「全部」,而且正好顶到裁切边 | -/// -/// 短标签这条退路也不行:中文「全部」= 2 × 16 = **32**,与文本格**一样宽、零余量**, -/// 「裁不裁」取决于字体渲染的小数位——一个查不出、也守不住的边界。所以图标条档的 -/// 「全部版本」**不画文字**:换成与本页第一级那个「全部」同一个网格图标 -/// ([`category_icon`] 的 `Category::All`),全称进 tooltip(宽度不设限,中英文都完整)。 -/// -/// 桶标签本来就是 3~5 字符的短串,两档都照旧画原文。 -pub(super) const fn major_shape(entry: MajorEntry<'_>, icon_only: bool) -> MajorShape<'_> { - if icon_only && entry.key.is_none() { - MajorShape::Icon(entry.label) - } else { - MajorShape::Text(entry.label) - } -} - -/// 第二级侧栏要画的全部项:`[全部版本, 1.21, 1.20, …]`,顺序 = 桶的版本序。 -/// -/// 抽成纯函数是为了让「最后一个桶可点」这件事**可断言**(复审 P2-2 的要求): -/// 竖栏与 chips 行都从这一份数据画,chips 行外面还套了横向滚动,所以「画得出来」= 「够得着」。 -/// 返回 `Vec` 而不是迭代器:调用方两处都要 len(chips 的宽度预算)与随机访问。 -pub(super) fn major_entries<'a>(catalog: Catalog, model: &'a Download) -> Vec> { - let mut entries = vec![MajorEntry { - key: None, - label: catalog.text(Key::VersionMajorAll), - selected: group::bucket_selected(model, None), - }]; - - for bucket in &model.buckets { - // `None` 桶 = 远古版那类没有主版本的版本:第二级只有一个「全部」,不画空桶 - let Some(key) = bucket.key.as_deref() else { - continue; - }; - entries.push(MajorEntry { - key: Some(key), - label: key, - selected: group::bucket_selected(model, Some(key)), - }); - } - - entries -} - -/// 主版本的一项:形状由 [`major_shape`] 决定(图标条档的「全部版本」是图标 + tooltip, -/// 其余都是 `ListItem` 单行文字)。 -fn major_item<'a>( - ui: &'a Ui, - entry: MajorEntry<'a>, - icon_only: bool, -) -> Element<'a, Message> { - let press = Message::SelectMajor(entry.key.map(str::to_string)); - - match major_shape(entry, icon_only) { - MajorShape::Text(label) => { - let mut item = ListItem::new(label, press); - if entry.selected { - item = item.selected(); - } - list_item(ui, item) - } - // 图标条档的「全部版本」:图标(与本页第一级的「全部」同一个网格)+ 全称 tooltip。 - // 高度对齐 `ListItem` 的行高,两档的行节奏才一样。 - MajorShape::Icon(full) => container(labeled( - ui, - full, - category_icon(Category::All), - entry.selected, - press, - )) - .width(Length::Fill) - .height(Length::Fixed(ui.metrics().row_height)) - .align_x(Alignment::Center) - .align_y(Alignment::Center) - .into(), - } -} +/// 高度固定成 `control_height`(40 = 图标按钮的边长):两条栏的标题必须**横向对齐**, /// 顶部 chips(可用宽 < 600 时三栏不成立:类型退回 Primary tabs,主版本变成 Secondary chips 行)。 /// diff --git a/crates/app-iced/src/pages/download/rails/major.rs b/crates/app-iced/src/pages/download/rails/major.rs new file mode 100644 index 0000000..11a7064 --- /dev/null +++ b/crates/app-iced/src/pages/download/rails/major.rs @@ -0,0 +1,162 @@ +//! 第二栏(主版本桶)与「一项画成什么样」的判定(从 `rails.rs` 拆出来守 400 行红线)。 +//! +//! 两条渲染路径吃同一份数据:[`major_entries`] 产出 `[全部版本, 1.21, 1.20, …]`, +//! 竖栏([`major_rail`])与 chips 行(`rails::stacked_chips`)各画一遍。 +//! 图标条档(64 宽)里「全部版本」换图标 + tooltip 的取舍见 [`major_shape`]。 + +use iced::widget::{column, container}; +use iced::{Alignment, Element, Length}; + +use unlucky_design::spacing::Space; + +use unlucky_i18n::{Catalog, Key}; + +use crate::app::components::navigation::list_item::{list_item, ListItem}; +use crate::pages::download::group; +use crate::pages::download::model::{Category, Download}; +use crate::pages::frame; +use crate::pages::layout::RAIL_ICON_WIDTH; +use crate::pages::Message; +use crate::theme::Ui; + +use super::{category_icon, labeled, rail_title}; + +/// 第二栏:主版本桶(第一项永远是「全部版本」;没有主版本概念的版本不单独占一项)。 +pub(in crate::pages::download) fn major_rail<'a>( + ui: &'a Ui, + catalog: Catalog, + model: &'a Download, + width: f32, +) -> Element<'a, Message> { + let icon_only = width <= RAIL_ICON_WIDTH; + + let mut body = column![].spacing(ui.space(Space::X1)).width(Length::Fill); + if !icon_only { + body = body.push(rail_title( + ui, + catalog.text(Key::VersionMajorTitle), + frame::gap(ui.metrics().nav_item_gap), + )); + } + + for entry in major_entries(catalog, model) { + body = body.push(major_item(ui, entry, icon_only)); + } + + body.into() +} + +/// 第二级侧栏的一项(「全部版本」与每个主版本桶共用一套结构,好让**两条渲染路径** +/// ——竖栏与 chips 行 —— 都用同一份数据,测试也只钉这一份)。 +/// +/// 字段全部**借**模型里的桶标签与目录里的静态串:这两条渲染路径都要把标签塞进 iced 控件树, +/// 借本地的 `String` 会让整棵树的生命周期塌到函数作用域里(E0515)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::pages::download) struct MajorEntry<'a> { + /// 选中它要发的键(`None` = 「全部版本」) + pub key: Option<&'a str>, + /// 画出来的标签 + pub label: &'a str, + /// 此刻是否选中(**按生效值算**:搜索期间恒为「全部」,见 `group::bucket_selected`) + pub selected: bool, +} + +/// 一项在 iced 里**画成什么样**(纯函数产出,单测直接钉它 —— 两种形状的取舍见 [`major_shape`])。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::pages::download) enum MajorShape<'a> { + /// `ListItem` 单行文字(展开档的每一项、图标条档的桶标签) + Text(&'a str), + /// 图标 + tooltip 全称(图标条档的「全部版本」,见 [`major_shape`] 的宽度账) + Icon(&'a str), +} + +/// 图标条档画成什么样:**只有「全部版本」这一项不是文字**。 +/// +/// ## 64 宽的可见文本宽是 **32**,不是 40(M123 取证实测) +/// +/// `ListItem` 两侧内边距各 16(`pad_start` / `pad_end`),所以 64 宽的栏里文本格 +/// = 64 − 32 = **32**,再长就被 `clip(true)` 切掉。`BodyLarge` 16px 下: +/// +/// | 标签 | 墨迹宽 | 32 里放得下? | +/// |---|---|---| +/// | `1.21` | 26(736×760 那张实测) | ✓ 余量 6 | +/// | `24w` | 26(同上) | ✓ 余量 6 | +/// | `All versions` | ≈84(12 字符,按 0.4em 下限也有 77) | ✗ 只画得出「All v」(缺陷 2 的原图) | +/// | `全部版本` | 4 × 16 = **64**(全角 1em,精确值) | ✗ 只画得出「全部」,而且正好顶到裁切边 | +/// +/// 短标签这条退路也不行:中文「全部」= 2 × 16 = **32**,与文本格**一样宽、零余量**, +/// 「裁不裁」取决于字体渲染的小数位——一个查不出、也守不住的边界。所以图标条档的 +/// 「全部版本」**不画文字**:换成与本页第一级那个「全部」同一个网格图标 +/// ([`category_icon`] 的 `Category::All`),全称进 tooltip(宽度不设限,中英文都完整)。 +/// +/// 桶标签本来就是 3~5 字符的短串,两档都照旧画原文。 +pub(in crate::pages::download) const fn major_shape( + entry: MajorEntry<'_>, + icon_only: bool, +) -> MajorShape<'_> { + if icon_only && entry.key.is_none() { + MajorShape::Icon(entry.label) + } else { + MajorShape::Text(entry.label) + } +} + +/// 第二级侧栏要画的全部项:`[全部版本, 1.21, 1.20, …]`,顺序 = 桶的版本序。 +/// +/// 抽成纯函数是为了让「最后一个桶可点」这件事**可断言**(复审 P2-2 的要求): +/// 竖栏与 chips 行都从这一份数据画,chips 行外面还套了横向滚动,所以「画得出来」= 「够得着」。 +/// 返回 `Vec` 而不是迭代器:调用方两处都要 len(chips 的宽度预算)与随机访问。 +pub(in crate::pages::download) fn major_entries<'a>( + catalog: Catalog, + model: &'a Download, +) -> Vec> { + let mut entries = vec![MajorEntry { + key: None, + label: catalog.text(Key::VersionMajorAll), + selected: group::bucket_selected(model, None), + }]; + + for bucket in &model.buckets { + // `None` 桶 = 远古版那类没有主版本的版本:第二级只有一个「全部」,不画空桶 + let Some(key) = bucket.key.as_deref() else { + continue; + }; + entries.push(MajorEntry { + key: Some(key), + label: key, + selected: group::bucket_selected(model, Some(key)), + }); + } + + entries +} + +/// 主版本的一项:形状由 [`major_shape`] 决定(图标条档的「全部版本」是图标 + tooltip, +/// 其余都是 `ListItem` 单行文字)。 +fn major_item<'a>(ui: &'a Ui, entry: MajorEntry<'a>, icon_only: bool) -> Element<'a, Message> { + let press = Message::SelectMajor(entry.key.map(str::to_string)); + + match major_shape(entry, icon_only) { + MajorShape::Text(label) => { + let mut item = ListItem::new(label, press); + if entry.selected { + item = item.selected(); + } + list_item(ui, item) + } + // 图标条档的「全部版本」:图标(与本页第一级的「全部」同一个网格)+ 全称 tooltip。 + // 高度对齐 `ListItem` 的行高,两档的行节奏才一样。 + MajorShape::Icon(full) => container(labeled( + ui, + full, + category_icon(Category::All), + entry.selected, + press, + )) + .width(Length::Fill) + .height(Length::Fixed(ui.metrics().row_height)) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .into(), + } +} diff --git a/crates/app-iced/src/pages/download/rows.rs b/crates/app-iced/src/pages/download/rows.rs index 531ce92..695f9fc 100644 --- a/crates/app-iced/src/pages/download/rows.rs +++ b/crates/app-iced/src/pages/download/rows.rs @@ -97,13 +97,10 @@ pub(super) fn content<'a>( // 列表与尾部面板之间**总是**有一条分隔线(三态一致):M123 的取证图里「选好了版本」 // 那一态原先没有它,版本行与选项区第一行(版本号 + 安装按钮)读起来像同一块内容。 - let tail: Element<'a, Message> = column![ - divider::full(ui), - install::panel(ui, catalog, model) - ] - .spacing(ui.space(Space::X3)) - .width(Length::Fill) - .into(); + let tail: Element<'a, Message> = column![divider::full(ui), install::panel(ui, catalog, model)] + .spacing(ui.space(Space::X3)) + .width(Length::Fill) + .into(); // chips 档:面板收回自己的一半、内部可滚(它原先按内容高钉死,把列表压成 18px 的缝)。 let tail: Element<'a, Message> = match share { diff --git a/crates/app-iced/src/pages/download/tests/i18n.rs b/crates/app-iced/src/pages/download/tests/i18n.rs index b5fa3ab..348af6c 100644 --- a/crates/app-iced/src/pages/download/tests/i18n.rs +++ b/crates/app-iced/src/pages/download/tests/i18n.rs @@ -17,7 +17,7 @@ use super::*; /// 它只看字面出现,用宏拼 key 名能骗过它。 #[test] fn every_download_key_is_referenced_by_the_page() { - const SOURCES: [&str; 11] = [ + const SOURCES: [&str; 12] = [ include_str!("../../download.rs"), include_str!("../model.rs"), // M107:分类枚举搬进 `model/category.rs`(`Category::label_key` 在那儿,漏扫会误判死 key) @@ -25,6 +25,8 @@ fn every_download_key_is_referenced_by_the_page() { include_str!("../rows.rs"), // M107:两级侧栏与分组规则(`VersionRailTitle` / `VersionMajorAll` 的落点) include_str!("../rails.rs"), + // M125:主版本栏搬进 `rails/major.rs`(`VersionMajorTitle` / `VersionMajorAll` 的落点) + include_str!("../rails/major.rs"), include_str!("../group.rs"), include_str!("../install.rs"), include_str!("../states.rs"), diff --git a/crates/app-iced/src/pages/download/tests/options_shot.rs b/crates/app-iced/src/pages/download/tests/options_shot.rs index 590fe58..c1bbba0 100644 --- a/crates/app-iced/src/pages/download/tests/options_shot.rs +++ b/crates/app-iced/src/pages/download/tests/options_shot.rs @@ -111,6 +111,40 @@ impl Shot { Some((top, bottom - top + 1)) } + /// `[from, to)` 里最长的一段**同色**横条:`(y, 长度)`(颜色必须与底色不同)。 + /// + /// 用它找面板里那条实心「安装」按钮:它是列表列里唯一一条 ≥ 80px 的实色带。 + fn longest_run( + &self, + left: usize, + right: usize, + from: usize, + to: usize, + background: [u8; 3], + ) -> (usize, usize) { + let mut best = (0, 0); + for y in from..to.min(self.height) { + let mut run = 0; + let mut color = background; + for x in left..right { + let pixel = self.at(x, y); + if pixel == background { + color = background; + run = 0; + } else if pixel == color { + run += 1; + } else { + color = pixel; + run = 1; + } + if run > best.1 { + best = (y, run); + } + } + } + best + } + /// 列表列里最后一行**有内容**的 y(`None` = 整列都是底色)。 fn last_ink_row(&self, left: usize, right: usize, background: [u8; 3]) -> Option { (0..self.height).rev().find(|y| { @@ -153,7 +187,13 @@ impl Shot { /// /// 用它量「版本列表到哪儿为止」:尾部面板上沿有一条通栏分隔线,它上面的高度就是列表拿到的。 /// 只认**薄**线(上下两行都与它不同色)——选中行的底色带也是通栏的,那是 44px 的块,不是线。 - fn first_rule_y(&self, left: usize, right: usize, from: usize, background: [u8; 3]) -> Option { + fn first_rule_y( + &self, + left: usize, + right: usize, + from: usize, + background: [u8; 3], + ) -> Option { let thin = |y: usize| { let color = self.at(left + 2, y); color != background @@ -194,10 +234,17 @@ fn bands(width: f32) -> Bands { left + rails.type_rail as usize + RAIL_GAP as usize, rails.major_rail, ); - let list = ( - major_rail.0 + rails.major_rail as usize + RAIL_GAP as usize, - rails.list, - ); + // chips 档没有两条侧栏(宽度都是 0):列表就是整条可用宽,左缘 = 页左缘。 + // 不分支的话会算成 `112 + 0 + 24 + 0 + 24 = 160`,右缘跟着冲出画面 22px + // (M125 缺陷 1 的守门就是在这里踩到「扫描越界读下一行像素」的)。 + let list = if rails.tier.is_three_column() { + ( + major_rail.0 + rails.major_rail as usize + RAIL_GAP as usize, + rails.list, + ) + } else { + (left, rails.list) + }; Bands { type_rail, major_rail, @@ -298,138 +345,8 @@ fn an_options_shot_shows_both_rails_and_the_whole_panel() { ); } -/// `02-…-chips700.png`(700×760,`ULL_DL_CHIPS_SHOT`)——**M125 缺陷 1 的回归守门**。 -/// -/// 修前(M123 取证,2026-11-05,tiny-skia,700×760,`ULL_DL_DEMO=options-fabric`): -/// chips 档多出两行 chip,选项区又整块钉在列表下面(316px),于是列表列只剩一条 **18px** 的缝, -/// 选中行的底色带实测 19px(行高 44)——版本号一个字读不出来,用户换不了版本。 -/// 修法见 `rows::TailShare`:chips 档改成「列表与面板各吃一半,面板自己可滚」。 -/// -/// ```text -/// ULL_DL_CHIPS_SHOT=02-options-fabric-en-light-chips700.png \ -/// cargo test -p unlucky-app-iced --lib options_shot -/// ``` -/// -/// 抓得住:①首行版本行的底色带 = 44(整行在屏);②**至少 3 整行**读得出来(数墨迹条数); -/// ③选项面板仍在屏内(列表列最后一行墨迹明显低于版本行区,且没贴到底边)—— -/// 「能换版本」与「能看见选项」是这条断言的成对验收。 -/// -/// 抓不住:文本有没有被**横向**裁掉(那要看字形宽度,见 `tests/rails.rs` 的标签断言与 -/// 报告里的量测表)、hover/tooltip 层(静态图拍不到)、面板内部滚动到底后长什么样 -/// (静态图只有一帧,只能拍到 scroll offset 0)。 -#[test] -fn a_chips_tier_shot_keeps_a_whole_version_row_readable() { - let Some(path) = shot_path(CHIPS_SHOT) else { - eprintln!("[options-shot] SKIPPED:未设置 {CHIPS_SHOT}。"); - return; - }; - let shot = Shot::load(&path); - let bands = bands(700.0); - let canvas = shot.at(bands.list.0 + 2, 12); - let metrics = ui().metrics(); +// ---- chips 档(缺陷 1)与 Compact 档(缺陷 2)的像素守门(拆出来守 400 行红线) - // ① 首行整行 44:这是 M123 原本写的那条(修好之后解 ignore,不许改期望值迁就现状) - let (row_top, row_height) = shot - .first_filled_band(bands.list.0 + 60, 360, 440, canvas) - .expect("列表列里应当有版本行"); - assert_eq!( - row_height as f32, metrics.row_height, - "Chips 档的版本行被压成 {row_height}px(应为 {})", - metrics.row_height - ); +mod chips; - // ② 版本列表那一块至少 3 整行高:尾部面板上沿的通栏分隔线就是列表的下界 - let right = bands.list.0 + bands.list.1 as usize - 2; - let rule = shot - .first_rule_y(bands.list.0, right, row_top, canvas) - .expect("找不到尾部面板上沿的分隔线(列表区的下界量不出来)"); - let rows_px = rule.saturating_sub(row_top) as f32; - assert!( - rows_px >= 3.0 * metrics.row_height, - "Chips 档的列表区只有 {rows_px}px(y={row_top}..{rule}),不足 3 整行({}):\ - 尾部面板把列表压扁了", - 3.0 * metrics.row_height - ); - - // ③ 面板在屏内:最下面一行墨迹明显低于版本行区(不是列表自己),也没贴到窗口底边 - let last = shot - .last_ink_row( - bands.list.0, - bands.list.0 + bands.list.1 as usize - 2, - canvas, - ) - .expect("列表列里应当有内容"); - assert!( - last > row_top + 300, - "选项面板没画出来:最后一行墨迹在 y={last},版本行从 y={row_top} 起" - ); - assert!( - last + 8 < shot.height, - "面板被窗口底边切掉:最后一行墨迹在 y={last}(图高 {})", - shot.height - ); -} - -/// `04-…-compact736.png`(736×760,`ULL_DL_COMPACT_SHOT`)——**Compact 档主版本栏(缺陷 2)**。 -/// -/// 修前(M123 取证):主版本栏宽 64,`ListItem` 两侧内边距各 16 ⇒ 文本格 32, -/// 而首项「All versions」要 ≈84 ⇒ 图上只剩「**All v**」。中文更糟:「全部版本」= 4 全角字 = 64, -/// 32 里只剩「全部」而且正好顶到裁切边。 -/// -/// 修后:图标条档的「全部版本」**不画文字**(`rails::major_shape` 的 `Icon` 分支)—— -/// 画成与本页第一级那个「全部」同一个网格图标(40×40 `Tonal` 药丸 + 全称 tooltip)。 -/// -/// 抓得住:①首项是 **40×40 的图标药丸**(不是 64 宽 × 44 高的通栏文字行)——谁把它改回 -/// 「画文字」(全称或短标签都会是通栏 44 行)这条立刻红;②药丸在 64 栏里**居中**(左右各 12)。 -/// 抓不住:图标字形对不对、tooltip 长什么样(静态图拍不到 tooltip;字形要看图)。 -/// -/// ## 为什么不用「文字墨迹右边界 ≤ 栏右内边界」这条(复审建议的那条) -/// -/// 它**在坏图上也是绿的**:修前那张图实测首项文字墨迹 x=216..244,而栏右内边界是 -/// `200 + 64 − 16 = 248` ⇒ 244 ≤ 248 成立——「All **v**」明明被裁掉了,断言却过。 -/// 原因是 `ListItem` 的 `clip(true)` 让墨迹**永远**停在裁切边之前,边界的比较量不出「被裁」。 -/// 所以这里改钉**形状**:文字行是 64×44 的通栏带,图标按钮是 40×40 的居中药丸,两者像素可分。 -/// -/// **中文为什么没有同档图**:本仓的截图通道 `pages::demo_model` 把 `Model::new(..)` 的 -/// `lang` 写死成 `Lang::En`(`pages/model.rs:120`),`ULL_LANG` 只作用到 app 的 `App`, -/// 拍不出中文页面 —— 所以中文的结论走「数据面断言 + 算术」: -/// `tests/rails.rs` 的 `only_the_first_major_entry_switches_to_an_icon_in_the_icon_rail` -/// (中文与英文走**同一条**图标分支,键判据 `entry.key.is_none()` 与语言无关)与 -/// `the_icon_rail_text_box_is_32_and_only_bucket_labels_fit`(「全部版本」= 64 > 32)。 -#[test] -fn a_compact_tier_shot_draws_an_icon_for_the_first_major_entry() { - let Some(path) = shot_path(COMPACT_SHOT) else { - eprintln!("[options-shot] SKIPPED:未设置 {COMPACT_SHOT}。"); - return; - }; - let shot = Shot::load(&path); - assert_eq!(shot.width, 736, "Compact 档那张必须是 736 宽"); - - let bands = bands(736.0); - let ui = ui(); - let rail_left = bands.major_rail.0; - let rail_right = rail_left + bands.major_rail.1 as usize; - let center = rail_left + bands.major_rail.1 as usize / 2; - // 栏的底色(`MaterialLayer::Card`):取栏内空白处,不取窗口画布 - let card = shot.at(rail_left + 2, 500); - - // ① 首项的竖向量:40(图标按钮的边长)——文字行会是 44 - let (top, height) = shot - .first_filled_band(center, 156, 300, card) - .expect("主版本栏里应当有第一项"); - assert_eq!( - height as f32, - ui.metrics().control_height, - "Compact 档主版本栏首项应当是 40 高的图标按钮(y={top}),实测 {height}px —— \ - 回到「ListItem 文字行」就是 44" - ); - - // ② 横向:40 宽的药丸,在 64 栏里居中(左右各 12) - let (start, len) = shot.fill_run(top + height / 2, rail_left, rail_right, center, card); - assert_eq!( - (start, len as f32), - (rail_left + 12, ui.metrics().control_height), - "首项应当是 40 宽的居中药丸(栏 {rail_left}..{rail_right}),实测 x={start} 宽 {len} —— \ - 64 宽的通栏行说明它又画成文字了" - ); -} +mod compact; 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 new file mode 100644 index 0000000..7f04530 --- /dev/null +++ b/crates/app-iced/src/pages/download/tests/options_shot/chips.rs @@ -0,0 +1,75 @@ +//! Chips 档(窗口 < 736、可用宽 < 600)的像素守门:M125 缺陷 1。 + +use super::{bands, shot_path, ui, Shot, CHIPS_SHOT}; + +/// `02-…-chips700.png`(700×760,`ULL_DL_CHIPS_SHOT`)——**M125 缺陷 1 的回归守门**。 +/// +/// 修前(M123 取证,2026-11-05,tiny-skia,700×760,`ULL_DL_DEMO=options-fabric`): +/// chips 档多出两行 chip,选项区又整块钉在列表下面(316px),于是列表列只剩一条 **18px** 的缝, +/// 选中行的底色带实测 19px(行高 44)——版本号一个字读不出来,用户换不了版本。 +/// 修法见 `rows::TailShare`:chips 档改成「列表与面板各吃一半,面板自己可滚」。 +/// +/// ```text +/// ULL_DL_CHIPS_SHOT=02-options-fabric-en-light-chips700.png \ +/// cargo test -p unlucky-app-iced --lib options_shot +/// ``` +/// +/// 抓得住:①首行版本行的底色带 = 44(整行在屏);②**列表区 ≥ 3 整行**(面板上沿那条通栏 +/// 分隔线就是下界);③分隔线下方 96px 内有一条 ≥ 80px 的实色带(面板头那条实心「安装」按钮)—— +/// 「能换版本」与「能看见选项面板」是这条断言的成对验收。 +/// +/// 抓不住:文本有没有被**横向**裁掉(那要看字形宽度,见 `tests/rails.rs` 的标签断言与 +/// 报告里的量测表)、hover/tooltip 层(静态图拍不到)、面板内部滚动到底后长什么样 +/// (静态图只有一帧,只能拍到 scroll offset 0)——所以这里**不**断言「面板没被窗口底边切」: +/// chips 档的面板本来就是可滚的一半,实例名那一格确实落在折线以下。 +#[test] +fn a_chips_tier_shot_keeps_a_whole_version_row_readable() { + let Some(path) = shot_path(CHIPS_SHOT) else { + eprintln!("[options-shot] SKIPPED:未设置 {CHIPS_SHOT}。"); + return; + }; + let shot = Shot::load(&path); + let bands = bands(700.0); + let canvas = shot.at(bands.list.0 + 2, 12); + let metrics = ui().metrics(); + + // ① 首行整行 44:这是 M123 原本写的那条(修好之后解 ignore,不许改期望值迁就现状) + let (row_top, row_height) = shot + .first_filled_band(bands.list.0 + 60, 360, 440, canvas) + .expect("列表列里应当有版本行"); + assert_eq!( + row_height as f32, metrics.row_height, + "Chips 档的版本行被压成 {row_height}px(应为 {})", + metrics.row_height + ); + + // ② 版本列表那一块至少 3 整行高:尾部面板上沿的通栏分隔线就是列表的下界 + let right = bands.list.0 + bands.list.1 as usize - 2; + let rule = shot + .first_rule_y(bands.list.0, right, row_top, canvas) + .expect("找不到尾部面板上沿的分隔线(列表区的下界量不出来)"); + let rows_px = rule.saturating_sub(row_top) as f32; + assert!( + rows_px >= 3.0 * metrics.row_height, + "Chips 档的列表区只有 {rows_px}px(y={row_top}..{rule}),不足 3 整行({}):\ + 尾部面板把列表压扁了", + 3.0 * metrics.row_height + ); + + // ③ 面板在屏内:分隔线下面紧接着就是面板的头(那条实心「安装」按钮 = 一条 ≥ 80px 的实色带) + let (y, run) = shot.longest_run(bands.list.0, right, rule + 4, rule + 96, canvas); + assert!( + run >= 80, + "选项面板没画出来:分隔线 y={rule} 下方 96px 里最长只有 {run}px 的同色带(y={y}),\ + 看不到那条实心「安装」按钮" + ); + + // ④ 面板确实占了下半块(列表区的下界以上都归列表,以下归面板) + let last = shot + .last_ink_row(bands.list.0, right, canvas) + .expect("列表列里应当有内容"); + assert!( + last > rule + 100, + "选项面板没有占到下半块:最后一行墨迹在 y={last},面板上沿在 y={rule}" + ); +} 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 new file mode 100644 index 0000000..5fb4afe --- /dev/null +++ b/crates/app-iced/src/pages/download/tests/options_shot/compact.rs @@ -0,0 +1,67 @@ +//! Compact 档(可用宽 600~675)主版本栏的像素守门:M125 缺陷 2。 + +use super::{bands, shot_path, ui, Shot, COMPACT_SHOT}; + +/// `04-…-compact736.png`(736×760,`ULL_DL_COMPACT_SHOT`)——**Compact 档主版本栏(缺陷 2)**。 +/// +/// 修前(M123 取证):主版本栏宽 64,`ListItem` 两侧内边距各 16 ⇒ 文本格 32, +/// 而首项「All versions」要 ≈84 ⇒ 图上只剩「**All v**」。中文更糟:「全部版本」= 4 全角字 = 64, +/// 32 里只剩「全部」而且正好顶到裁切边。 +/// +/// 修后:图标条档的「全部版本」**不画文字**(`rails::major_shape` 的 `Icon` 分支)—— +/// 画成与本页第一级那个「全部」同一个网格图标(40×40 `Tonal` 药丸 + 全称 tooltip)。 +/// +/// 抓得住:①首项是 **40×40 的图标药丸**(不是 64 宽 × 44 高的通栏文字行)——谁把它改回 +/// 「画文字」(全称或短标签都会是通栏 44 行)这条立刻红;②药丸在 64 栏里**居中**(左右各 12)。 +/// 抓不住:图标字形对不对、tooltip 长什么样(静态图拍不到 tooltip;字形要看图)。 +/// +/// ## 为什么不用「文字墨迹右边界 ≤ 栏右内边界」这条(复审建议的那条) +/// +/// 它**在坏图上也是绿的**:修前那张图实测首项文字墨迹 x=216..244,而栏右内边界是 +/// `200 + 64 − 16 = 248` ⇒ 244 ≤ 248 成立——「All **v**」明明被裁掉了,断言却过。 +/// 原因是 `ListItem` 的 `clip(true)` 让墨迹**永远**停在裁切边之前,边界的比较量不出「被裁」。 +/// 所以这里改钉**形状**:文字行是 64×44 的通栏带,图标按钮是 40×40 的居中药丸,两者像素可分。 +/// +/// **中文为什么没有同档图**:本仓的截图通道 `pages::demo_model` 把 `Model::new(..)` 的 +/// `lang` 写死成 `Lang::En`(`pages/model.rs:120`),`ULL_LANG` 只作用到 app 的 `App`, +/// 拍不出中文页面 —— 所以中文的结论走「数据面断言 + 算术」: +/// `tests/rails.rs` 的 `only_the_first_major_entry_switches_to_an_icon_in_the_icon_rail` +/// (中文与英文走**同一条**图标分支,键判据 `entry.key.is_none()` 与语言无关)与 +/// `the_icon_rail_text_box_is_32_and_only_bucket_labels_fit`(「全部版本」= 64 > 32)。 +#[test] +fn a_compact_tier_shot_draws_an_icon_for_the_first_major_entry() { + let Some(path) = shot_path(COMPACT_SHOT) else { + eprintln!("[options-shot] SKIPPED:未设置 {COMPACT_SHOT}。"); + return; + }; + let shot = Shot::load(&path); + assert_eq!(shot.width, 736, "Compact 档那张必须是 736 宽"); + + let bands = bands(736.0); + let ui = ui(); + let rail_left = bands.major_rail.0; + let rail_right = rail_left + bands.major_rail.1 as usize; + let center = rail_left + bands.major_rail.1 as usize / 2; + // 栏的底色(`MaterialLayer::Card`):取栏内空白处,不取窗口画布 + let card = shot.at(rail_left + 2, 500); + + // ① 首项的竖向量:40(图标按钮的边长)——文字行会是 44 + let (top, height) = shot + .first_filled_band(center, 156, 300, card) + .expect("主版本栏里应当有第一项"); + assert_eq!( + height as f32, + ui.metrics().control_height, + "Compact 档主版本栏首项应当是 40 高的图标按钮(y={top}),实测 {height}px —— \ + 回到「ListItem 文字行」就是 44" + ); + + // ② 横向:40 宽的药丸,在 64 栏里居中(左右各 12) + let (start, len) = shot.fill_run(top + height / 2, rail_left, rail_right, center, card); + assert_eq!( + (start, len as f32), + (rail_left + 12, ui.metrics().control_height), + "首项应当是 40 宽的居中药丸(栏 {rail_left}..{rail_right}),实测 x={start} 宽 {len} —— \ + 64 宽的通栏行说明它又画成文字了" + ); +} diff --git a/crates/app-iced/src/pages/download/tests/rails.rs b/crates/app-iced/src/pages/download/tests/rails.rs index ff469e4..51c555b 100644 --- a/crates/app-iced/src/pages/download/tests/rails.rs +++ b/crates/app-iced/src/pages/download/tests/rails.rs @@ -211,7 +211,10 @@ fn only_the_first_major_entry_switches_to_an_icon_in_the_icon_rail() { let first = entries[0]; assert_eq!(first.key, None, "{lang:?}:第一项是「全部版本」"); - assert_eq!(first.label, full, "{lang:?}:数据层仍带全称(chips 档要它)"); + assert_eq!( + first.label, full, + "{lang:?}:数据层仍带全称(chips 档要它)" + ); assert_eq!( major_shape(first, true), MajorShape::Icon(full), From 9c623a6fd3fffb913e49501f419a1d208b1fbfae Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 16:03:10 +0800 Subject: [PATCH 3/4] =?UTF-8?q?style(app-iced):=20M125=20=E6=8B=86?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=97=B6=E7=95=99=E4=B8=8B=E7=9A=84=E5=AD=A4?= =?UTF-8?q?=E5=84=BF=E6=96=87=E6=A1=A3=E7=89=87=E6=AE=B5=EF=BC=88clippy=20?= =?UTF-8?q?=E7=9A=84=20empty=5Fline=5Fafter=5Fdoc=5Fcomments=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/app-iced/src/pages/download/rails.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/app-iced/src/pages/download/rails.rs b/crates/app-iced/src/pages/download/rails.rs index 518ab42..fb5cf38 100644 --- a/crates/app-iced/src/pages/download/rails.rs +++ b/crates/app-iced/src/pages/download/rails.rs @@ -251,10 +251,6 @@ fn fold_action<'a>(ui: &'a Ui, model: &Download, icon_only: bool) -> Element<'a, ) } -/// 栏标题(`LabelMedium` + `OnSurfaceVariant`,与列表计数头同一档字级)。 -/// -/// 高度固定成 `control_height`(40 = 图标按钮的边长):两条栏的标题必须**横向对齐**, - /// 顶部 chips(可用宽 < 600 时三栏不成立:类型退回 Primary tabs,主版本变成 Secondary chips 行)。 /// /// 与三栏档**同源**:同一批 [`Category`] / 同一批主版本桶 / 同两条消息,不是另造一套筛选状态。 From 0f26ed37537b60697597e257f9e898fed1ce1f2e Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 16:43:35 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(app-iced):=20M125=20r2=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20chips=20=E6=A1=A3=E3=80=8C=E8=BF=98=E6=B2=A1?= =?UTF-8?q?=E9=80=89=E7=89=88=E6=9C=AC=E3=80=8D=E4=B8=8D=E5=86=8D=E5=AF=B9?= =?UTF-8?q?=E5=8D=8A=E5=88=87=EF=BC=9B=E5=AE=BD=E5=BA=A6=E8=A1=A8=E6=8D=A2?= =?UTF-8?q?=E6=88=90=E5=AE=9E=E6=B5=8B=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r1 复审 p2-1:`TailShare::of` 只判档位,于是 chips 档「刚进页面」(面板只有一行 提示)也被五五分——700×760 下列表 8 个版本只显示 4 个、下半块 ≈160px 空画布, 而修前(main)这一态是 7 整行。修法:判据加**面板形态**,`Hint` 在任何档位都钉死, `Chips` + `Options`/`Install` 才共享。实测回到 7 整行(与 main 同一水位)。 守门:新增 `options_shot/ready.rs`(`ULL_DL_READY_SHOT`,不设 `ULL_DL_DEMO` 出的 700×760 图):列表区至少读出 6 个版本号。它在 r1 的图上**红**(4 个,原生输出见 mission 报告),在修后图上绿(7 个)。三条旧守门照旧(chips / compact / wide), chips 与 compact 换修前图仍红。 r1 复审 p2-2:宽度表的实测值改正(同一张 736 图逐项量): `1.21` 26(余 6)、`1.20` 28(余 4)、`24w` **29**(余 3)、`All versions` 墨迹画到 x=247(裁切边 248,只差 1px)⇒ 桶标签余量是 **3~6px** 而不是 6;「短标签『全部』= 32 零余量」这条论证的对比强度随之写准(比桶标签的 3~6px 更薄)。 `rails/major.rs` 的表格、`options_shot/compact.rs` 的测试文档一并改成实测值; 「墨迹右边界」那条反例也从 216..244 改成实测 216..247。 r1 复审 p3-1:第二级「全部版本」的图标从网格(与第一级同字形同药丸、像素级同形) 换成文档/清单字形 `IconKind::Logs`,理由与「图标集里没有版本/历史字形」写进 `major_shape` 文档。 r1 复审 p3-2:chips 档最多三层滚动(列表 / 面板 / loader 菜单)作为**已知风险** 登记在 `install.rs` 文档里,并写明为什么「把实例名移出滚动区」本轮不做(要拆 `options::section`,动三个文件,而且改完仍无法验证滚轮冒泡——没有通道)。 --- crates/app-iced/src/pages/download.rs | 7 +- crates/app-iced/src/pages/download/install.rs | 20 ++++- crates/app-iced/src/pages/download/rails.rs | 2 +- .../src/pages/download/rails/major.rs | 57 +++++++----- crates/app-iced/src/pages/download/rows.rs | 34 +++++--- .../src/pages/download/tests/options_shot.rs | 32 +++++++ .../download/tests/options_shot/compact.rs | 23 +++-- .../download/tests/options_shot/ready.rs | 79 +++++++++++++++++ .../app-iced/src/pages/download/tests/view.rs | 87 +++++++++++++++---- 9 files changed, 283 insertions(+), 58 deletions(-) create mode 100644 crates/app-iced/src/pages/download/tests/options_shot/ready.rs diff --git a/crates/app-iced/src/pages/download.rs b/crates/app-iced/src/pages/download.rs index a928caa..af799fd 100644 --- a/crates/app-iced/src/pages/download.rs +++ b/crates/app-iced/src/pages/download.rs @@ -228,7 +228,12 @@ fn body<'a>( column![ rails::stacked_chips(ui, catalog, model), divider::full(ui), - rows::content(ui, catalog, model, rows::TailShare::of(rails.tier)), + rows::content( + ui, + catalog, + model, + rows::TailShare::of(rails.tier, &install::panel_state(model)), + ), ] .spacing(ui.space(Space::X2)) .width(Length::Fill) diff --git a/crates/app-iced/src/pages/download/install.rs b/crates/app-iced/src/pages/download/install.rs index ec6e124..c656643 100644 --- a/crates/app-iced/src/pages/download/install.rs +++ b/crates/app-iced/src/pages/download/install.rs @@ -11,7 +11,25 @@ //! //! **M125 的例外**:chips 档(窗口 < 736,竖栏退回顶部 chip 行)里这块面板太贵——它按内容高 //! 钉死会把版本列表压成 18px 的缝。那一档改成「列表与面板各吃一半、面板自己可滚」 -//! (`rows::TailShare::Shared`),所以「钉在底部」只对三栏档成立。 +//! (`rows::TailShare::Shared`)——但只在**面板真的占高度**时(`Options` / `Install`); +//! 只画一行提示的 `Hint` 态仍钉死,否则 8 个版本的列表会被砍到 4 行(r1 复审 P2-1)。 +//! +//! ## 已知风险:chips 档最多会同时出现**三层**滚动(r1 复审 P3-2,未验) +//! +//! ```text +//! 版本行列表 scrollable ← 第 1 层(任何档位都在) +//! └ 面板 scrollable(chips 档才有) ← 第 2 层 +//! └ 加载器版本 menu 的 scrollable ← 第 3 层(`options/view.rs` 的 `VERSION_ROWS`) +//! ``` +//! +//! **鼠标落在第 3 层上滚轮会不会冒泡到第 2 层,本仓没有实测通道**(静态图拍不到滚动嵌套)。 +//! iced 的 `scrollable` 在指针悬停于内层时由内层消费滚动,所以「滚到底之后继续滚会带动外层」 +//! 属于**未验证行为**;两个方向的坏结果分别是「面板卡住滚不动」与「一滚就跳到外层」。 +//! +//! 缓解方案(本轮**没做**,理由:不是一处小改)——把「实例名」那一格从面板的滚动区里移出去, +//! 做成面板的固定页脚:那要把 `options::section` 拆成「可滚的上半块 + 钉死的下半块」, +//! 动 `options/view.rs` / `install.rs` / `rows.rs` 三处,而且改完仍然**无法**验证滚轮冒泡 +//! (没有通道),只是把风险从「某一格被挡」换成「面板永不挡那一格」。所以先登记为已知风险。 //! //! ## 只有意图,没有乐观回显 //! diff --git a/crates/app-iced/src/pages/download/rails.rs b/crates/app-iced/src/pages/download/rails.rs index fb5cf38..0c35a46 100644 --- a/crates/app-iced/src/pages/download/rails.rs +++ b/crates/app-iced/src/pages/download/rails.rs @@ -91,7 +91,7 @@ pub(super) fn three_column<'a>( ui, catalog, model, - rows::TailShare::of(rails.tier), + rows::TailShare::of(rails.tier, &super::install::panel_state(model)), )) .width(Length::Fixed(rails.list)) .height(Length::Fill), diff --git a/crates/app-iced/src/pages/download/rails/major.rs b/crates/app-iced/src/pages/download/rails/major.rs index 11a7064..4bb7eff 100644 --- a/crates/app-iced/src/pages/download/rails/major.rs +++ b/crates/app-iced/src/pages/download/rails/major.rs @@ -11,15 +11,17 @@ use unlucky_design::spacing::Space; use unlucky_i18n::{Catalog, Key}; +use unlucky_design::icons::IconKind; + use crate::app::components::navigation::list_item::{list_item, ListItem}; use crate::pages::download::group; -use crate::pages::download::model::{Category, Download}; +use crate::pages::download::model::Download; use crate::pages::frame; use crate::pages::layout::RAIL_ICON_WIDTH; use crate::pages::Message; use crate::theme::Ui; -use super::{category_icon, labeled, rail_title}; +use super::{labeled, rail_title}; /// 第二栏:主版本桶(第一项永远是「全部版本」;没有主版本概念的版本不单独占一项)。 pub(in crate::pages::download) fn major_rail<'a>( @@ -77,19 +79,32 @@ pub(in crate::pages::download) enum MajorShape<'a> { /// `ListItem` 两侧内边距各 16(`pad_start` / `pad_end`),所以 64 宽的栏里文本格 /// = 64 − 32 = **32**,再长就被 `clip(true)` 切掉。`BodyLarge` 16px 下: /// -/// | 标签 | 墨迹宽 | 32 里放得下? | +/// | 标签 | 墨迹(736×760 实测) | 32 的文本格里放得下? | /// |---|---|---| -/// | `1.21` | 26(736×760 那张实测) | ✓ 余量 6 | -/// | `24w` | 26(同上) | ✓ 余量 6 | -/// | `All versions` | ≈84(12 字符,按 0.4em 下限也有 77) | ✗ 只画得出「All v」(缺陷 2 的原图) | -/// | `全部版本` | 4 × 16 = **64**(全角 1em,精确值) | ✗ 只画得出「全部」,而且正好顶到裁切边 | +/// | `1.21` | 26px(x=217..242) | ✓ 余量 **6** | +/// | `1.20` | 28px(x=217..244) | ✓ 余量 **4** | +/// | `24w` | 29px(x=216..244) | ✓ 余量 **3**(最紧的一条) | +/// | `All versions` | 墨迹画到 x=247,裁切边在 248 | ✗ 被切:实际只画出「All v」(缺陷 2 的原图) | +/// | `全部版本` | 4 全角字 × 16 = **64**(精确值,不是估计) | ✗ 只画得出「全部」,且顶到裁切边 | /// -/// 短标签这条退路也不行:中文「全部」= 2 × 16 = **32**,与文本格**一样宽、零余量**, -/// 「裁不裁」取决于字体渲染的小数位——一个查不出、也守不住的边界。所以图标条档的 -/// 「全部版本」**不画文字**:换成与本页第一级那个「全部」同一个网格图标 -/// ([`category_icon`] 的 `Category::All`),全称进 tooltip(宽度不设限,中英文都完整)。 +/// 桶标签的余量是 **3~6px**(视字形:`24w` 最紧 3,`1.21` 最松 6)。短标签「全部」= 2 × 16 = **32**, +/// 与文本格**一样宽、零余量**——比桶标签那 3~6px 更薄,裁不裁取决于字体渲染的小数位, +/// 是一个查不出也守不住的边界,所以「换短标签」这条退路不采用。于是图标条档的 +/// 「全部版本」**不画文字**:换成图标(字形见 [`major_shape`] 的下文), +/// 全称进 tooltip(宽度不设限,中英文都完整)。 /// /// 桶标签本来就是 3~5 字符的短串,两档都照旧画原文。 +/// +/// ## 用哪个字形(r1 复审 P3-1) +/// +/// 这里用 **`IconKind::Logs`(文档 + 正文行)**,不用第一级「全部类型」那个网格 +/// (`category_icon(Category::All)`):Compact 档两条 64 宽的栏在屏上相邻,若两级都用网格 + +/// 同一个 40×40 `Tonal` 药丸,**第一级「全部」与第二级「全部版本」像素级同形**, +/// 只能靠 hover 出 tooltip 才分得清是哪个轴。文档/清单字形读作「整份版本清单」, +/// 与网格(「全部类型」)分得开。 +/// +/// 更贴切的「版本 / 历史 / 层叠」字形本仓没有(图标语汇只有 28 个描边字形,登记在 +/// `unlucky_design::icons`),新增字形要动 `crates/design`(超出本页范围),此处按既有字形借用。 pub(in crate::pages::download) const fn major_shape( entry: MajorEntry<'_>, icon_only: bool, @@ -146,17 +161,13 @@ fn major_item<'a>(ui: &'a Ui, entry: MajorEntry<'a>, icon_only: bool) -> Element } // 图标条档的「全部版本」:图标(与本页第一级的「全部」同一个网格)+ 全称 tooltip。 // 高度对齐 `ListItem` 的行高,两档的行节奏才一样。 - MajorShape::Icon(full) => container(labeled( - ui, - full, - category_icon(Category::All), - entry.selected, - press, - )) - .width(Length::Fill) - .height(Length::Fixed(ui.metrics().row_height)) - .align_x(Alignment::Center) - .align_y(Alignment::Center) - .into(), + MajorShape::Icon(full) => { + container(labeled(ui, full, IconKind::Logs, entry.selected, press)) + .width(Length::Fill) + .height(Length::Fixed(ui.metrics().row_height)) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .into() + } } } diff --git a/crates/app-iced/src/pages/download/rows.rs b/crates/app-iced/src/pages/download/rows.rs index 695f9fc..553f65e 100644 --- a/crates/app-iced/src/pages/download/rows.rs +++ b/crates/app-iced/src/pages/download/rows.rs @@ -28,9 +28,11 @@ //! 固定 ≈316px(版本行 40 + 标题 16 + 五个 loader chip 34 + 标题 16 + 菜单 112 + 实例名 56)。 //! 同样的 760 高窗口里:三栏档列表还剩 158px(3 整行),chips 档只剩 18px。 //! -//! 修法是 [`TailShare`] 两档:三栏档照旧钉死(面板整块在屏内),chips 档改成两半各 `Fill` -//! ——列表拿到 ≈182px(**4 整行**),面板拿到同样一半、**自己可滚**(顶端那个实心「安装」 -//! 按钮与五个 loader chip 仍在屏内,实例名那一格滚一下就到)。 +//! 修法是 [`TailShare`] 两档:三栏档照旧钉死(面板整块在屏内),chips 档**且面板真的占高度** +//! (选项区展开 / 安装中)时改成两半各 `Fill`——列表拿到 ≈182px(**4 整行**),面板拿到同样一半、 +//! **自己可滚**(顶端那个实心「安装」按钮与五个 loader chip 仍在屏内,实例名那一格滚一下就到)。 +//! 「刚进页面还没选版本」(`Hint`,面板只有一行提示)**不共享**:那一态 8 个版本能全在屏, +//! 五五分会砍成 4 行、下面留 ≈160px 空白(r1 复审 P2-1 的回归)。 //! //! 这条判据按**档位**而不是按高度:页面的模型里只有 `window_width`,窗口高度没接进页面, //! 所以「760 高到底够不够」在运行时是未知量(边界写在 [`TailShare`] 的文档里)。 @@ -70,8 +72,9 @@ use super::super::frame; use super::super::layout::rails::{LIST_DATE_WIDTH, LIST_KIND_WIDTH, ROW_GAP, ROW_ICON_BUTTON}; use super::super::layout::VersionRailTier; use super::super::Message; +use super::install::{self, PanelState}; use super::model::{Download, VersionRow}; -use super::{install, states}; +use super::states; /// 列表列(三栏档与 chips 档共用一份)。 /// @@ -129,7 +132,8 @@ pub(super) fn content<'a>( /// | 档 | 分法 | 为什么 | /// |---|---|---| /// | `Wide` / `Narrow` / `Compact` | [`TailShare::Pinned`]:面板按内容高钉在底部,列表吃剩下的 | 竖栏档在 760 高的窗口里列表还剩 158px(3 整行),面板整块在屏内——两样都对 | -/// | `Chips` | [`TailShare::Shared`]:两半各 `Fill`,面板自己可滚 | chips 档比三栏档多花两行 chip(tabs 48 + chips 48 + 间距),列表只剩 **18px**(M123 实测 0.4 行),面板 316px 全在屏内也没用——版本号读不出来就换不了版本 | +/// | `Chips` + 面板**占高度**(选项区展开 / 安装中) | [`TailShare::Shared`]:两半各 `Fill`,面板自己可滚 | chips 档比三栏档多花两行 chip(tabs 48 + chips 48 + 间距),列表只剩 **18px**(M123 实测 0.4 行),面板 316px 全在屏内也没用——版本号读不出来就换不了版本 | +/// | 任何档 + 只画 `Hint`(没选版本,面板只有一行) | [`TailShare::Pinned`] | 一行提示不值得对半分:700×760 下 8 个版本能全在屏,对半分只剩 4 个、下面留 160px 空白(r1 复审 P2-1) | /// /// 判据是**档位**不是高度:页面的模型里只有 `window_width`(`pages/model.rs`),窗口高度 /// 没有接进页面(要接得改 `pages/mod.rs` 的入口签名,超出本页的边界)。所以这里按「哪一档 @@ -143,13 +147,21 @@ pub(super) enum TailShare { } impl TailShare { - /// 档位 → 分高方式(纯函数,单测直接钉它)。 - pub(super) const fn of(tier: VersionRailTier) -> Self { - match tier { - VersionRailTier::Chips => Self::Shared, - VersionRailTier::Wide | VersionRailTier::Narrow | VersionRailTier::Compact => { - Self::Pinned + /// 档位 + **面板形态** → 分高方式(纯函数,单测直接钉它)。 + /// + /// 两个条件必须同时成立才共享: + /// 1. 档位是 `Chips`(多花两行 chip,竖排预算最紧的那一档); + /// 2. 面板真的**占高度**——`Options`(选中了版本、选项区展开)或 `Install`(进度 / 失败)。 + /// + /// `Hint`(刚进页面还没选版本)只是**一行**提示(≈30px),对半分会让 8 个版本只显示 4 个、 + /// 下半块留 ≈160px 空画布(r1 复审 P2-1 实测:修前 700×760 下 8 行全在屏,只判档位的写法 + /// 把它压到 4 行)。所以 `Hint` **在任何档位都钉死**。 + pub(super) const fn of(tier: VersionRailTier, state: &PanelState<'_>) -> Self { + match (tier, state) { + (VersionRailTier::Chips, PanelState::Options(_) | PanelState::Install(_)) => { + Self::Shared } + _ => Self::Pinned, } } } diff --git a/crates/app-iced/src/pages/download/tests/options_shot.rs b/crates/app-iced/src/pages/download/tests/options_shot.rs index c1bbba0..d3d26c8 100644 --- a/crates/app-iced/src/pages/download/tests/options_shot.rs +++ b/crates/app-iced/src/pages/download/tests/options_shot.rs @@ -41,6 +41,8 @@ const WIDE_SHOT: &str = "ULL_DL_OPTIONS_SHOT"; const CHIPS_SHOT: &str = "ULL_DL_CHIPS_SHOT"; /// 736 宽那张(`04-options-fabric-en-light-compact736.png`)——Compact 档主版本栏用。 const COMPACT_SHOT: &str = "ULL_DL_COMPACT_SHOT"; +/// 700 宽、**不设 `ULL_DL_DEMO`**(= `ready`,还没选版本)那张——r1 复审 P2-1 的守门用。 +const READY_SHOT: &str = "ULL_DL_READY_SHOT"; fn ui() -> Ui { Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES) @@ -183,6 +185,34 @@ impl Shot { (start, end - start + 1) } + /// `[from, to)` 里在 `[left, right)` 有墨迹的**条数**(两条之间空 ≥ 8 行才算两条)。 + /// + /// 用它数「读得出几个版本号」:一行版本号的文本墨迹约 11px 高、行距 44,中间空 30+ ⇒ + /// 一条对应一行。被压扁时整块只剩一条。 + fn ink_bands( + &self, + left: usize, + right: usize, + from: usize, + to: usize, + background: [u8; 3], + ) -> usize { + let mut bands = 0; + let mut gap = usize::MAX; + for y in from..to.min(self.height) { + let inked = (left..right).any(|x| self.at(x, y) != background); + if inked { + if gap >= 8 { + bands += 1; + } + gap = 0; + } else { + gap = gap.saturating_add(1); + } + } + bands + } + /// 列表列里、`from` 起的**第一条薄通栏横线**的 y(`divider::full` 画的那条 1px 线)。 /// /// 用它量「版本列表到哪儿为止」:尾部面板上沿有一条通栏分隔线,它上面的高度就是列表拿到的。 @@ -350,3 +380,5 @@ fn an_options_shot_shows_both_rails_and_the_whole_panel() { mod chips; mod compact; + +mod ready; 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 5fb4afe..d1e30ed 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 @@ -4,12 +4,22 @@ use super::{bands, shot_path, ui, Shot, COMPACT_SHOT}; /// `04-…-compact736.png`(736×760,`ULL_DL_COMPACT_SHOT`)——**Compact 档主版本栏(缺陷 2)**。 /// -/// 修前(M123 取证):主版本栏宽 64,`ListItem` 两侧内边距各 16 ⇒ 文本格 32, -/// 而首项「All versions」要 ≈84 ⇒ 图上只剩「**All v**」。中文更糟:「全部版本」= 4 全角字 = 64, -/// 32 里只剩「全部」而且正好顶到裁切边。 +/// 修前(M123 取证;本文件作者在 736×760 那张图上逐项量过):主版本栏宽 64,`ListItem` +/// 两侧内边距各 16 ⇒ 文本格 **32**(x=216..247,裁切边 248): +/// +/// | 标签 | 墨迹 | 结论 | +/// |---|---|---| +/// | `1.21` | 26px | 完整(余 6) | +/// | `1.20` | 28px | 完整(余 4) | +/// | `24w` | 29px | 完整(余 3,最紧) | +/// | `All versions` | 画到 x=247(裁切边 248) | **被切**:只剩「All v」 | +/// | `全部版本` | 4 全角字 = 64 > 32 | **被切**:只剩「全部」且顶到边 | +/// +/// 即桶标签的余量是 **3~6px**,而「全部版本」这条根本放不下。 /// /// 修后:图标条档的「全部版本」**不画文字**(`rails::major_shape` 的 `Icon` 分支)—— -/// 画成与本页第一级那个「全部」同一个网格图标(40×40 `Tonal` 药丸 + 全称 tooltip)。 +/// 画成文档/清单字形(40×40 `Tonal` 药丸 + 全称 tooltip)。**不是**第一级那个网格: +/// 两条 64 栏相邻,同字形 + 同药丸会让「全部类型」与「全部版本」像素级同形(r1 复审 P3-1)。 /// /// 抓得住:①首项是 **40×40 的图标药丸**(不是 64 宽 × 44 高的通栏文字行)——谁把它改回 /// 「画文字」(全称或短标签都会是通栏 44 行)这条立刻红;②药丸在 64 栏里**居中**(左右各 12)。 @@ -17,8 +27,9 @@ use super::{bands, shot_path, ui, Shot, COMPACT_SHOT}; /// /// ## 为什么不用「文字墨迹右边界 ≤ 栏右内边界」这条(复审建议的那条) /// -/// 它**在坏图上也是绿的**:修前那张图实测首项文字墨迹 x=216..244,而栏右内边界是 -/// `200 + 64 − 16 = 248` ⇒ 244 ≤ 248 成立——「All **v**」明明被裁掉了,断言却过。 +/// 它**在坏图上也是绿的**:修前那张图实测首项文字墨迹 x=216..247,而栏右内边界是 +/// `200 + 64 − 16 = 248` ⇒ 247 ≤ 248 成立(只剩 1px 余量)——「All **v**」明明被裁掉了, +/// 断言却过。这条比 r1 报告里写的更极端:差 1px 而不是 4px。 /// 原因是 `ListItem` 的 `clip(true)` 让墨迹**永远**停在裁切边之前,边界的比较量不出「被裁」。 /// 所以这里改钉**形状**:文字行是 64×44 的通栏带,图标按钮是 40×40 的居中药丸,两者像素可分。 /// diff --git a/crates/app-iced/src/pages/download/tests/options_shot/ready.rs b/crates/app-iced/src/pages/download/tests/options_shot/ready.rs new file mode 100644 index 0000000..1235051 --- /dev/null +++ b/crates/app-iced/src/pages/download/tests/options_shot/ready.rs @@ -0,0 +1,79 @@ +//! 「还没选版本」那一态(`ULL_DL_DEMO` 不设 = `ready`)的像素守门:r1 复审 P2-1。 +//! +//! ## 为什么要单独守这一态 +//! +//! M125 r1 的 `TailShare::of` **只判档位**,于是 chips 档的「刚进页面」也被五五分: +//! 面板里只有一行提示(`PanelState::Hint`,≈30px),却拿走一半高度 ⇒ 700×760 下 +//! 8 个版本只显示 **4** 个、下半块留 ≈160px 空画布(修前是 8 行全在屏)。 +//! 同目录 `chips.rs` 只拍「选中 + 选项区展开」,正是这个盲区把回归放了过去。 +//! +//! 现在的判据:`Chips` **且**面板真占高度(`Options` / `Install`)才共享; +//! `Hint` 在任何档位都钉死(见 `rows::TailShare::of`)。 +//! +//! ```text +//! # 不设 ULL_DL_DEMO(= ready)出 700×760 那张 +//! cargo run -p unlucky-app-iced --example pages_demo -- \ +//! --screen=download --width=700 --height=760 --screenshot=ready700.png \ +//! --screenshot-theme=light --software-renderer +//! ULL_DL_READY_SHOT=ready700.png cargo test -p unlucky-app-iced --lib options_shot +//! ``` +//! +//! 抓得住:①列表区 **≥ 6 整行**(数版本号那列的墨迹条数,行距 44);②尾部(提示那条 +//! 分隔线)在 6 行之后——五五分时它落在第 4 行下方,两条都立刻红。 +//! 抓不住:提示文案本身、hover/tooltip 层(静态图拍不到);也不管「选中且展开」那一态 +//!(那是 `chips.rs` 的活)。 + +use super::{bands, shot_path, ui, Shot, READY_SHOT}; + +/// `ready` 那一态(700×760):8 个版本里至少 6 行要在屏,尾部提示不许把列表压扁。 +/// +/// 修前实测(M125 r1,`TailShare::of` 只判档位的版本):列表区 `374..559` = 185px = 4 行, +/// 版本号那列只有 4 条墨迹;面板那半块 185px 里只有一行 `Pick a version, then install`, +/// 其余是空白 —— 这条断言在那种图上是红的(4 < 6)。 +#[test] +fn a_ready_shot_keeps_the_whole_visible_list() { + let Some(path) = shot_path(READY_SHOT) else { + eprintln!("[options-shot] SKIPPED:未设置 {READY_SHOT}。"); + return; + }; + let shot = Shot::load(&path); + assert_eq!(shot.width, 700, "这条只认 700×760 那张(chips 档)"); + + let bands = bands(700.0); + let canvas = shot.at(bands.list.0 + 2, 12); + let metrics = ui().metrics(); + let right = bands.list.0 + bands.list.1 as usize - 2; + + // ① 第一行版本号的墨迹带(ready 态没有选中行,所以量的是**文本**而不是底色; + // x 取 `+100` 落在版本号那几个字上——`+60` 会落进「前置下载图标」与文字之间的空档) + let (first_ink, _) = shot + .first_filled_band(bands.list.0 + 100, 360, 440, canvas) + .expect("列表列里应当有版本行"); + + // ② 尾部(提示)上沿的分隔线:它上面的高度才是列表拿到的 + let rule = shot + .first_rule_y(bands.list.0, right, first_ink, canvas) + .expect("找不到尾部那条分隔线(列表区的下界量不出来)"); + + // ③ 数得出几个版本号:8 行里至少 6 行在屏 + let rows = shot.ink_bands( + bands.list.0 + 50, + bands.list.0 + 140, + first_ink, + rule, + canvas, + ); + assert!( + rows >= 6, + "ready 态只读得出 {rows} 个版本号(要求 ≥ 6 个,演示数据共 8 个):\ + 面板那一行提示把列表对半切了(列表区 y={first_ink}..{rule},\ + 共 {}px,行高 {})", + rule - first_ink, + metrics.row_height + ); + assert!( + (rule - first_ink) as f32 >= 6.0 * metrics.row_height - metrics.row_height / 2.0, + "ready 态的列表区只有 {}px(y={first_ink}..{rule}),不足 6 整行", + rule - first_ink + ); +} diff --git a/crates/app-iced/src/pages/download/tests/view.rs b/crates/app-iced/src/pages/download/tests/view.rs index ecb2410..9a6a781 100644 --- a/crates/app-iced/src/pages/download/tests/view.rs +++ b/crates/app-iced/src/pages/download/tests/view.rs @@ -146,37 +146,94 @@ fn the_demo_data_is_sane() { assert!(demo.rows.iter().all(|row| !row.supporting.contains('版'))); } -/// 列表与尾部面板怎么分高,跟着**档位**走(M125 缺陷 1 的决策面)。 +/// 列表与尾部面板怎么分高:**档位 × 面板形态**两个条件同时成立才对半分。 /// -/// 修前:面板一律按内容高钉在底部,700×760 的 chips 档里列表只剩 18px(0.4 行)。 -/// 修后:chips 档两半各 `Fill`(面板自己可滚),三栏三档照旧钉死(760 高下列表还有 3 整行、 -/// 面板整块在屏内,两样都对)。 +/// 修前(M123):面板一律按内容高钉在底部,700×760 的 chips 档里列表只剩 18px(0.4 行)。 +/// M125 r1 修法只判档位,又踩出另一条回归:chips 档「刚进页面还没选版本」(`Hint`,面板只有 +/// 一行提示)也被五五分,700×760 下 8 个版本只显示 4 个、下半块留 ≈160px 空画布 +/// (复审 P2-1 实测,修前是 8 行全在屏)。 /// -/// 抓得住:有人把 chips 档改回 `Pinned`(700 下的像素守门 `a_chips_tier_shot_…` 立刻红); -/// 或者把三栏档也改成 `Shared`(1180 下选项面板被切成一半,`an_options_shot_…` 会红)。 -/// 抓不住:**实际分到多少像素**(`iced` 的 `Element` 没有几何内省,那只能出图量, -/// 见 `options_shot` 的像素断言与报告里的数值表)。 +/// 现在的判据:`Chips` **且**面板真占高度(`Options` / `Install`)才 `Shared`; +/// `Hint` 在任何档位都 `Pinned`。 +/// +/// 抓得住:①chips 档改回「只判档位」(`Hint` 跟着对半分 ⇒ 列表掉到 4 行,像素守门 +/// `ready::a_ready_shot_keeps_the_whole_visible_list` 立刻红);②三栏档也改成 `Shared` +/// (1180 下选项面板被切成一半,`an_options_shot_…` 会红)。 +/// 抓不住:**实际分到多少像素**(`iced` 的 `Element` 没有几何内省,那只能出图量)。 #[test] -fn the_chips_tier_shares_the_height_and_the_rails_tiers_pin_it() { +fn the_chips_tier_shares_the_height_only_when_the_panel_takes_room() { + use crate::pages::download::install::panel_state; + use crate::pages::download::options::{InstallDraft, Loader, OptionsView}; use crate::pages::download::rows::TailShare; use crate::pages::layout::VersionRailTier; + use unlucky_presenter::state::AppState; - assert_eq!(TailShare::of(VersionRailTier::Chips), TailShare::Shared); + let catalog = Lang::En.catalog(); + let mut app = AppState::new(); + app.apply(unlucky_presenter::dto::AppEvent::Core( + unlucky_presenter::dto::CoreEvent::VersionListChanged(vec![version( + "1.21.9", + VersionKind::Release, + false, + )]), + )); + + let row = VersionRow::of(&version("1.21.9", VersionKind::Release, false), catalog); + // 选中版本 ⇒ 选项区(面板真的占高度) + let options = Download { + options: Some(OptionsView::of( + &row, + &app, + &InstallDraft { + selected: Some("1.21.9".to_string()), + loader: Loader::Vanilla, + loader_version: String::new(), + instance_name: String::new(), + }, + catalog, + )), + install: None, + loaded: true, + ..Download::default() + }; + // 刚进页面 ⇒ 只有一行提示 + let hint = Download { + options: None, + install: None, + loaded: true, + ..Download::default() + }; + + assert_eq!( + TailShare::of(VersionRailTier::Chips, &panel_state(&options)), + TailShare::Shared, + "chips 档 + 选项区展开:对半分(列表 4 整行、面板自己可滚)" + ); + assert_eq!( + TailShare::of(VersionRailTier::Chips, &panel_state(&hint)), + TailShare::Pinned, + "chips 档 + 只有一行提示:钉死(8 个版本要在屏)" + ); for tier in [ VersionRailTier::Wide, VersionRailTier::Narrow, VersionRailTier::Compact, ] { - assert_eq!(TailShare::of(tier), TailShare::Pinned, "{tier:?}"); + assert_eq!( + TailShare::of(tier, &panel_state(&options)), + TailShare::Pinned, + "{tier:?}:三栏档一律钉死(面板整块在屏)" + ); + assert_eq!(TailShare::of(tier, &panel_state(&hint)), TailShare::Pinned); } - // 两个装配点(三栏装配 / chips 分支)都**按档位**传,不许在某一处写死一个字面量 + // 两个装配点(三栏装配 / chips 分支)都**按档位 + 面板形态**传 let chips_branch = include_str!("../../download.rs"); let three_column = include_str!("../rails.rs"); assert_eq!( - chips_branch.matches("TailShare::of(rails.tier)").count() - + three_column.matches("TailShare::of(rails.tier)").count(), + chips_branch.matches("TailShare::of(rails.tier,").count() + + three_column.matches("TailShare::of(rails.tier,").count(), 2, - "三栏装配与 chips 分支都要按档位传 TailShare" + "三栏装配与 chips 分支都要把面板形态传进 TailShare::of" ); }