Files
Unlucky-Launcher/crates/app-iced/src/pages/download/rows.rs
T
AskaEth 89a51a63b1 feat(app-iced): M107 DLVRAIL —— 版本管理页三栏侧栏 + 跨类型搜索
用户令(信息架构):这一页从「下载」改称「版本管理」,版面从「一排 tab + 平铺列表」
改成**页面内三栏**:版本类型栏 + 主版本栏 + 列表/详情,并加搜索框。

- **三栏几何**(`pages/layout/rails.rs`,纯函数 + 数值表):类型栏 140/64、主版本栏 140/64、
  间距 24、列表列下限 424(行宽红线)。四档由**可用宽**裁决:Wide ≥888 / Narrow ≥676 /
  Compact ≥600 / Chips <600,三个门槛都是 `栏宽 + 间距 + 424` 算出来的,不是拍的
- **主版本分组规则**(`download/group.rs`):正式版/预发布按 `major.minor`(`1.21.9-pre3` → `1.21`)、
  周更按年桶(`24w14a` → `24w`)、远古版**没有主版本**(`rd-132211`/`a1.2.6` → 第二级只有「全部」);
  判据用真实版本样本钉住,不按「第一个点」切
- **跨类型搜索**(`search_bar` + 清空 + 命中数):查询词写进 `AppState.download_query`(页面无状态,
  只能这么活),`hits` 是**跨分类**读数;三条口径分开:「有命中」/「没有匹配」/「命中在别的类型里」
- 两级侧栏都用 `navigation::list_item` / `tabs` / `chip` / `buttons::icon`:Tab 可达、hover/pressed/
  选中态全部来自组件,页面不自己写状态层
- 右栏展开(M104 `content_width` 852)⇒ 可用宽 716 ⇒ Narrow 档,列表 464(任务书那句 492 漏扣
  96px 的页边距与栏间距,报告里给了完整数值表)
- 文案 11 条中英进 `crates/i18n`(新域宏接在装配链末尾 ⇒ `Key::ALL` 只追加,金表只追加)
- 护栏:两层侧栏都存在 + 列表按第二级选中态过滤(变异可证)、三档门槛、424 下限扫描、
  版本行单行宽账、搜索条真的装着
- `install.rs` 只动了「已装」分支(加一句「已安装」解释禁用按钮),没碰 M105 的选项区

取证:`DevDocs/artifacts/m107/`(10 张:三档 × 浅深 + 搜索命中 + 空 + 异地 + 远古版)
2026-11-05 01:59:13 +08:00

269 lines
9.8 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.
//! 列表列:搜索条 + 计数头 + **单行**版本行 + 底部安装面板。
//!
//! ## 这一列的职责边界
//!
//! | 位置 | 画什么 | 消息 |
//! |---|---|---|
//! | 搜索条 | 真实控件的 `search_bar`(56 高、药丸)+ 清空按钮 + 命中数 | [`Message::SetVersionQuery`] / [`Message::ClearVersionQuery`] |
//! | 计数头 | `{count} versions`(`LabelMedium` + `OnSurfaceVariant`,与实例页列表头同一个语言) | 无 |
//! | 列表 | `ListItem` + 三列短文本 + 尾随图标按钮 | [`Message::SelectVersion`] / [`Message::InstallVersion`] |
//! | 面板 | 见 [`super::install`](M105 的安装选项区**不在这里**) | — |
//!
//! ## 版本行为什么必须是**单行**
//!
//! 任务硬约束:列表列最窄 424(行宽下限),此时「版本号 + 类型 + 日期 + 安装按钮」四样仍要在
//! 一行里。所以:版本号那一格是 `ListItem`(它自己的 `Wrapping::None` 保证不折行,超长由外层
//! clip 切掉),类型 / 日期 / 安装按钮三格是**定宽**([`super::super::layout::rails`] 的音阶)。
//! 折行的唯一可能来源是定宽格子挤爆容器——那条由列表列下限 424 挡住(`layout/rails/tests.rs`
//! 的扫描断言)。
//!
//! ## 搜索的三条口径
//!
//! | 情形 | 画什么 | 为什么 |
//! |---|---|---|
//! | 有命中 | 列表照常,侧栏**位置不动**,搜索条右侧显示「N 个匹配」 | 命中数是「我搜的词有货」的读数,不是分页读数 |
//! | 一个都不命中(跨类型也没有) | [`super::states::no_match`]:一句「没有匹配」+ 一句「换短一点的片段」 | 白屏会让人以为列表坏了 |
//! | 有命中但都不在当前类型 | [`super::states::elsewhere`]:说清「有 N 个在别的类型里」+ 一个「看全部类型」按钮 | 否则用户会以为自己搜错了 |
//!
//! ## 行距归零(M102,与本页的单行版本行同时成立)
//!
//! M102 把列表行距归零:原先页面在 `ListItem` 的可视高度之外又加了 `Space::X1`(4),行节奏
//! 与可视高度除不尽,实例页复现成「末行被卡片底边切半行」(视觉复审 ②)。规则:行距只由
//! 组件自己的几何决定,`Space` 里也没有 0 这一档 ⇒ `list()` 里**不写** `.spacing(…)`。
//! 本页的行是 44 的单行档,同样不加行距;[`ROW_GAP`] 只用在**行内**那三个格子的间距上。
use iced::widget::{column, container, row, scrollable};
use iced::{Alignment, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use crate::app::components::buttons::{self, IconVariant};
use crate::app::components::divider;
use crate::app::components::inputs::{search_bar, SearchBar};
use crate::app::components::navigation::list_item::{list_item, ListItem};
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::Message;
use super::model::{Download, VersionRow};
use super::{install, states};
/// 列表列(三栏档与 chips 档共用一份)。
pub(super) fn content<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Download,
) -> Element<'a, Message> {
let visible = visible(model);
let body: Element<'a, Message> = if model.no_match() {
states::no_match(ui, catalog)
} else if model.only_elsewhere() {
states::elsewhere(ui, catalog, model.hits)
} else if visible.is_empty() {
states::empty(ui, catalog)
} else {
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)
};
column![
search_row(ui, catalog, model),
divider::full(ui),
head(ui, catalog, visible.len()),
body,
tail,
]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.height(Length::Fill)
.into()
}
/// 搜索条 + 清空按钮 + 命中数(三者一行;命中数只在有查询词时出现)。
pub(super) fn search_row<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Download,
) -> Element<'a, Message> {
let bar = search_bar(
ui,
SearchBar::new(model.query.as_str())
.placeholder(catalog.text(Key::VersionSearchPlaceholder)),
Message::SetVersionQuery,
);
let mut line = row![container(bar).width(Length::Fill)]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center)
.width(Length::Fill);
if model.has_query() {
// 命中数是**跨类型**的读数(`Download::hits`),所以它在「当前分类没命中」时也照画
line = line.push(frame::text_role_owned(
ui,
TypeRole::LabelMedium,
false,
catalog.render(
Key::VersionSearchHits,
&[("count", &model.hits.to_string())],
),
ui.color(Role::OnSurfaceVariant),
));
line = line.push(buttons::icon(
ui,
IconVariant::Standard,
IconKind::Close,
Some(Message::ClearVersionQuery),
None,
));
}
line.into()
}
/// 计数头(`LabelMedium`):一行 = 一个 key,数字走占位符,不拼片段。
pub(super) fn head<'a>(ui: &'a Ui, catalog: Catalog, count: usize) -> Element<'a, Message> {
frame::text_role_owned(
ui,
TypeRole::LabelMedium,
false,
catalog.render(Key::DownloadCount, &[("count", &count.to_string())]),
ui.color(Role::OnSurfaceVariant),
)
}
/// 当前可见的行:先按分类(模型已过滤),再按第二级侧栏选中的主版本桶。
fn visible(model: &Download) -> Vec<&VersionRow> {
match model.selected_major.as_deref() {
None => model.rows.iter().collect(),
Some(key) => model
.rows
.iter()
.filter(|entry| entry.major().as_deref() == Some(key))
.collect(),
}
}
/// 版本行列表(**独立滚动**:只有列表滚,安装面板钉在底部不跟着滚)。
fn list<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Download,
visible: &[&'a VersionRow],
) -> Element<'a, Message> {
let entries = column(
visible
.iter()
.map(|entry| version_row(ui, catalog, model, entry))
.collect::<Vec<_>>(),
)
// M102:行距归零(见模块文档),只留横向留白
.padding(Padding::from([0.0, ui.space(Space::X2)]))
.width(Length::Fill);
scrollable(entries)
.direction(scrollable::Direction::Vertical(
scrollable::Scrollbar::new()
.width(frame::SCROLLBAR_WIDTH)
.scroller_width(frame::SCROLLBAR_WIDTH)
.margin(frame::SCROLLBAR_MARGIN),
))
.height(Length::Fill)
.style(frame::scroll_style(ui))
.into()
}
/// 一行:`ListItem`(版本号,可点选)+ 类型 + 日期 + 安装图标按钮(**四样同一行**)。
///
/// 点版本号 = [`Message::SelectVersion`](页面内选中,不 dispatch);点尾随按钮 =
/// [`Message::InstallVersion`]。已装的版本给勾 + 禁用按钮(不重复安装)。
fn version_row<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &Download,
entry: &'a VersionRow,
) -> Element<'a, Message> {
let mut item = ListItem::new(entry.id.as_str(), Message::SelectVersion(entry.id.clone()))
.leading(IconKind::Download);
if entry.installed {
item = item.trailing(IconKind::Check);
}
if model.selected.as_deref() == Some(entry.id.as_str()) {
item = item.selected();
}
// 版本号那一格吃掉剩下的宽(`ListItem` 自己是 `Wrapping::None`:再长也**不折行**,
// 由外层 clip 切掉)。定宽的三格因此永远落在右边,行宽下限 424 保证它们不会被挤出容器。
row![
container(list_item(ui, item)).width(Length::Fill),
cell(
ui,
catalog.text(super::model::kind_label_key(entry.kind)),
LIST_KIND_WIDTH
),
cell(ui, entry.released.as_str(), LIST_DATE_WIDTH),
install_action(ui, entry),
]
.spacing(ui.space(ROW_GAP))
.align_y(Alignment::Center)
.width(Length::Fill)
.into()
}
/// 一格定宽文本(`LabelMedium` + `OnSurfaceVariant`;单行,超长由外层 clip)。
fn cell<'a>(ui: &'a Ui, text: &'a str, width: f32) -> Element<'a, Message> {
container(frame::text_role(
ui,
TypeRole::LabelMedium,
false,
text,
ui.color(Role::OnSurfaceVariant),
))
.width(Length::Fixed(width))
.into()
}
/// 行尾的安装按钮:已装 → `Check`(`Primary`);未装 → `Download` 图标按钮(命中区 40/48)。
///
/// 「本页唯一实心 accent」留在底部面板那个带文字的「安装」按钮上(rubric K2),行内用标准图标按钮:
/// 一行一个实心药丸会让列表变成一排强调色。
fn install_action<'a>(ui: &'a Ui, entry: &'a VersionRow) -> Element<'a, Message> {
if entry.installed {
return container(frame::glyph(
IconKind::Check,
ui.color(Role::Primary),
frame::ICON_PX,
))
.width(Length::Fixed(ROW_ICON_BUTTON))
.align_x(Alignment::Center)
.into();
}
container(buttons::icon(
ui,
IconVariant::Standard,
IconKind::Download,
Some(Message::InstallVersion(entry.id.clone())),
None,
))
.width(Length::Fixed(ROW_ICON_BUTTON))
.align_x(Alignment::Center)
.into()
}