Files
Unlucky-Launcher/crates/app-iced/src/pages/download/rails/major.rs
T
AskaEth 70dea995b7 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 那条换深色图同样绿(断言不依赖主题色)。
2026-11-05 16:01:03 +08:00

163 lines
6.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 第二栏(主版本桶)与「一项画成什么样」的判定(从 `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(),
}
}