Merge branch 'feat/m125-railfix-chips-tier-crushed-list-and'
This commit is contained in:
commit
631d4647dc
12 files changed
+874
-159
No files matched your search
@@ -223,11 +223,17 @@ 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, &install::panel_state(model)),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill)
|
||||
|
||||
@@ -9,6 +9,28 @@
|
||||
//! 列表装的是几十行版本;底部按钮如果跟着滚,用户在列表中间永远看不到「安装」在哪。
|
||||
//! 所以列表独立滚动(`rows::list`),面板固定占尾部一块高度。
|
||||
//!
|
||||
//! **M125 的例外**:chips 档(窗口 < 736,竖栏退回顶部 chip 行)里这块面板太贵——它按内容高
|
||||
//! 钉死会把版本列表压成 18px 的缝。那一档改成「列表与面板各吃一半、面板自己可滚」
|
||||
//! (`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` 三处,而且改完仍然**无法**验证滚轮冒泡
|
||||
//! (没有通道),只是把风险从「某一格被挡」换成「面板永不挡那一格」。所以先登记为已知风险。
|
||||
//!
|
||||
//! ## 只有意图,没有乐观回显
|
||||
//!
|
||||
//! | 控件 | 消息 | app 层落点 |
|
||||
|
||||
@@ -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};
|
||||
|
||||
/// 类型栏里一项的图标。
|
||||
///
|
||||
@@ -79,9 +87,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, &super::install::panel_state(model)),
|
||||
))
|
||||
.width(Length::Fixed(rails.list))
|
||||
.height(Length::Fill),
|
||||
]
|
||||
.spacing(super::super::layout::rails::RAIL_GAP)
|
||||
.width(Length::Fill)
|
||||
@@ -165,19 +178,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()
|
||||
}
|
||||
|
||||
@@ -190,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 = 图标按钮的边长):两条栏的标题必须**横向对齐**,
|
||||
@@ -236,85 +235,20 @@ 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.label, entry.key, entry.selected));
|
||||
}
|
||||
|
||||
body.into()
|
||||
}
|
||||
|
||||
/// 第二级侧栏的一项(「全部版本」与每个主版本桶共用一套结构,好让**两条渲染路径**
|
||||
/// ——竖栏与 chips 行 —— 都用同一份数据,测试也只钉这一份)。
|
||||
///
|
||||
/// 字段全部**借**模型里的桶标签与目录里的静态串:这两条渲染路径都要把标签塞进 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,
|
||||
}
|
||||
|
||||
/// 第二级侧栏要画的全部项:`[全部版本, 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
|
||||
}
|
||||
|
||||
/// 主版本的一项:`ListItem` 单行(64 宽里 `1.21` 仍完整——`BodyLarge` 4 字符 ≈ 32px ≤ 64−32 内边距)。
|
||||
fn major_item<'a>(
|
||||
ui: &'a Ui,
|
||||
label: &'a str,
|
||||
key: Option<&str>,
|
||||
selected: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let mut item = ListItem::new(label, Message::SelectMajor(key.map(str::to_string)));
|
||||
if selected {
|
||||
item = item.selected();
|
||||
}
|
||||
list_item(ui, item)
|
||||
buttons::icon(
|
||||
ui,
|
||||
select_variant(model.type_rail_folded),
|
||||
IconKind::Sidebar,
|
||||
Some(Message::ToggleTypeRail),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// 顶部 chips(可用宽 < 600 时三栏不成立:类型退回 Primary tabs,主版本变成 Secondary chips 行)。
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! 第二栏(主版本桶)与「一项画成什么样」的判定(从 `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 unlucky_design::icons::IconKind;
|
||||
|
||||
use crate::app::components::navigation::list_item::{list_item, ListItem};
|
||||
use crate::pages::download::group;
|
||||
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::{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 下:
|
||||
///
|
||||
/// | 标签 | 墨迹(736×760 实测) | 32 的文本格里放得下? |
|
||||
/// |---|---|---|
|
||||
/// | `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**(精确值,不是估计) | ✗ 只画得出「全部」,且顶到裁切边 |
|
||||
///
|
||||
/// 桶标签的余量是 **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,
|
||||
) -> 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, IconKind::Logs, entry.selected, press))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(ui.metrics().row_height))
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,26 @@
|
||||
//! 折行的唯一可能来源是定宽格子挤爆容器——那条由列表列下限 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 仍在屏内,实例名那一格滚一下就到)。
|
||||
//! 「刚进页面还没选版本」(`Hint`,面板只有一行提示)**不共享**:那一态 8 个版本能全在屏,
|
||||
//! 五五分会砍成 4 行、下面留 ≈160px 空白(r1 复审 P2-1 的回归)。
|
||||
//!
|
||||
//! 这条判据按**档位**而不是按高度:页面的模型里只有 `window_width`,窗口高度没接进页面,
|
||||
//! 所以「760 高到底够不够」在运行时是未知量(边界写在 [`TailShare`] 的文档里)。
|
||||
//!
|
||||
//! ## 搜索的三条口径
|
||||
//!
|
||||
//! | 情形 | 画什么 | 为什么 |
|
||||
@@ -50,15 +70,21 @@ 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::install::{self, PanelState};
|
||||
use super::model::{Download, VersionRow};
|
||||
use super::{install, states};
|
||||
use super::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 +98,20 @@ 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 {
|
||||
install::panel(ui, catalog, model)
|
||||
// 列表与尾部面板之间**总是**有一条分隔线(三态一致):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,45 @@ 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 全在屏内也没用——版本号读不出来就换不了版本 |
|
||||
/// | 任何档 + 只画 `Hint`(没选版本,面板只有一行) | [`TailShare::Pinned`] | 一行提示不值得对半分:700×760 下 8 个版本能全在屏,对半分只剩 4 个、下面留 160px 空白(r1 复审 P2-1) |
|
||||
///
|
||||
/// 判据是**档位**不是高度:页面的模型里只有 `window_width`(`pages/model.rs`),窗口高度
|
||||
/// 没有接进页面(要接得改 `pages/mod.rs` 的入口签名,超出本页的边界)。所以这里按「哪一档
|
||||
/// 的竖排 chrome 会把列表压没」来分,并在报告里登记这条判据的边界。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum TailShare {
|
||||
/// 面板钉在底部(三栏档)。
|
||||
Pinned,
|
||||
/// 面板与列表各吃一半(chips 档)。
|
||||
Shared,
|
||||
}
|
||||
|
||||
impl TailShare {
|
||||
/// 档位 + **面板形态** → 分高方式(纯函数,单测直接钉它)。
|
||||
///
|
||||
/// 两个条件必须同时成立才共享:
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 搜索条 + 清空按钮 + 命中数(三者一行;命中数只在有查询词时出现)。
|
||||
pub(super) fn search_row<'a>(
|
||||
ui: &'a Ui,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -37,8 +37,12 @@ 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";
|
||||
/// 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)
|
||||
@@ -109,6 +113,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| {
|
||||
@@ -123,6 +161,78 @@ 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, 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 线)。
|
||||
///
|
||||
/// 用它量「版本列表到哪儿为止」:尾部面板上沿有一条通栏分隔线,它上面的高度就是列表拿到的。
|
||||
/// 只认**薄**线(上下两行都与它不同色)——选中行的底色带也是通栏的,那是 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 区间,全部从**产品自己的裁决函数**推出来(不是手抄的像素坐标)。
|
||||
@@ -154,10 +264,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,
|
||||
@@ -258,32 +375,10 @@ fn an_options_shot_shows_both_rails_and_the_whole_panel() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `02-options-fabric-en-light-chips700.png`(700×760)——**这条现在是红的**(M123 的缺陷登记)。
|
||||
///
|
||||
/// 现状(2026-11-05,tiny-skia,700×760,`ULL_DL_DEMO=options-fabric`):Chips 档多出两行 chip,
|
||||
/// 选项区又整块钉在列表下面,于是列表列只剩 ~22px 的缝,选中行的底色带实测 19px(行高 44)。
|
||||
/// 断言写的是**该有的**结果;修好之后删掉 `#[ignore]` 就是它的回归守门。
|
||||
///
|
||||
/// ```text
|
||||
/// ULL_DL_CHIPS_SHOT=02-options-fabric-en-light-chips700.png \
|
||||
/// cargo test -p unlucky-app-iced --lib options_shot -- --ignored
|
||||
/// ```
|
||||
#[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}。");
|
||||
return;
|
||||
};
|
||||
let shot = Shot::load(&path);
|
||||
let bands = bands(700.0);
|
||||
let canvas = shot.at(bands.list.0 + 2, 12);
|
||||
let (_, 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)"
|
||||
);
|
||||
}
|
||||
// ---- chips 档(缺陷 1)与 Compact 档(缺陷 2)的像素守门(拆出来守 400 行红线)
|
||||
|
||||
mod chips;
|
||||
|
||||
mod compact;
|
||||
|
||||
mod ready;
|
||||
@@ -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,78 @@
|
||||
//! 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 取证;本文件作者在 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)。**不是**第一级那个网格:
|
||||
/// 两条 64 栏相邻,同字形 + 同药丸会让「全部类型」与「全部版本」像素级同形(r1 复审 P3-1)。
|
||||
///
|
||||
/// 抓得住:①首项是 **40×40 的图标药丸**(不是 64 宽 × 44 高的通栏文字行)——谁把它改回
|
||||
/// 「画文字」(全称或短标签都会是通栏 44 行)这条立刻红;②药丸在 64 栏里**居中**(左右各 12)。
|
||||
/// 抓不住:图标字形对不对、tooltip 长什么样(静态图拍不到 tooltip;字形要看图)。
|
||||
///
|
||||
/// ## 为什么不用「文字墨迹右边界 ≤ 栏右内边界」这条(复审建议的那条)
|
||||
///
|
||||
/// 它**在坏图上也是绿的**:修前那张图实测首项文字墨迹 x=216..247,而栏右内边界是
|
||||
/// `200 + 64 − 16 = 248` ⇒ 247 ≤ 248 成立(只剩 1px 余量)——「All **v**」明明被裁掉了,
|
||||
/// 断言却过。这条比 r1 报告里写的更极端:差 1px 而不是 4px。
|
||||
/// 原因是 `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 宽的通栏行说明它又画成文字了"
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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,87 @@ 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,95 @@ fn the_demo_data_is_sane() {
|
||||
.all(|row| row.supporting.contains("released")));
|
||||
assert!(demo.rows.iter().all(|row| !row.supporting.contains('版')));
|
||||
}
|
||||
|
||||
/// 列表与尾部面板怎么分高:**档位 × 面板形态**两个条件同时成立才对半分。
|
||||
///
|
||||
/// 修前(M123):面板一律按内容高钉在底部,700×760 的 chips 档里列表只剩 18px(0.4 行)。
|
||||
/// M125 r1 修法只判档位,又踩出另一条回归:chips 档「刚进页面还没选版本」(`Hint`,面板只有
|
||||
/// 一行提示)也被五五分,700×760 下 8 个版本只显示 4 个、下半块留 ≈160px 空画布
|
||||
/// (复审 P2-1 实测,修前是 8 行全在屏)。
|
||||
///
|
||||
/// 现在的判据:`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_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;
|
||||
|
||||
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, &panel_state(&options)),
|
||||
TailShare::Pinned,
|
||||
"{tier:?}:三栏档一律钉死(面板整块在屏)"
|
||||
);
|
||||
assert_eq!(TailShare::of(tier, &panel_state(&hint)), TailShare::Pinned);
|
||||
}
|
||||
|
||||
// 两个装配点(三栏装配 / 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::of"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user