refactor(app-iced): M125 主版本栏与选项区像素守门各拆一层(守 400 行红线)
两处拆分,只为过 `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 那条换深色图同样绿(断言不依赖主题色)。
This commit is contained in:
1 parent
822f98022d
commit
70dea995b7
8 files changed
+394
-297
No files matched your search
@@ -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<MajorEntry<'a>> {
|
||||
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 行)。
|
||||
///
|
||||
|
||||
@@ -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<MajorEntry<'a>> {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<usize> {
|
||||
(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<usize> {
|
||||
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
|
||||
@@ -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;
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
@@ -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 宽的通栏行说明它又画成文字了"
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user