fix(app-iced): M125 chips 档列表不被选项区压成缝;Compact 档主版本首项不再裁字

两条缺陷都是 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 的任何断言期望值。
This commit is contained in:
AskaEth committed 2026-11-05 15:48:57 +08:00
1 parent df0e98d811
commit 822f98022d
7 files changed
+444 -47

No files matched your search

+3 -2
View File
@@ -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)
@@ -9,6 +9,10 @@
//! 列表装的是几十行版本;底部按钮如果跟着滚,用户在列表中间永远看不到「安装」在哪。
//! 所以列表独立滚动(`rows::list`),面板固定占尾部一块高度。
//!
//! **M125 的例外**:chips 档(窗口 < 736,竖栏退回顶部 chip 行)里这块面板太贵——它按内容高
//! 钉死会把版本列表压成 18px 的缝。那一档改成「列表与面板各吃一半、面板自己可滚」
//! (`rows::TailShare::Shared`),所以「钉在底部」只对三栏档成立。
//!
//! ## 只有意图,没有乐观回显
//!
//! | 控件 | 消息 | app 层落点 |
+89 -25
View File
@@ -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<Ma
entries
}
/// 主版本的一项:`ListItem` 单行(64 宽里 `1.21` 仍完整——`BodyLarge` 4 字符 ≈ 32px ≤ 64−32 内边距)。
/// 主版本的一项:形状由 [`major_shape`] 决定(图标条档的「全部版本」是图标 + tooltip,
/// 其余都是 `ListItem` 单行文字)。
fn major_item<'a>(
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 行)。
+69 -6
View File
@@ -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,
@@ -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<usize> {
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 宽的通栏行说明它又画成文字了"
);
}
@@ -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<VersionSummary> = ["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:放得下,照旧画文字"
);
}
@@ -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"
);
}