Files
Unlucky-Launcher/crates/app-iced/src/pages/download/model.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

368 lines
16 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.
//! 下载页视图模型:**presenter 快照 → 页面要画的东西**(与 [`super::super::model`] 同一套分层)。
//!
//! ## 为什么这一页也要中间层
//!
//! 页面层不认识 presenter 的 DTO(同实例页的理由):真页面吃 `AppState` 的快照,
//! 演示数据与单测吃 [`super::demo`],两边都转成这里的纯展示字段。
//!
//! 文案在这一层就按 [`Catalog`] 求好值(`supporting` / `detail` / `rate` 都是 `String`):
//! iced 的文字控件**借用**内容,行内文案又是 `format!` 出来的,不先落成字段就没法进控件树
//! (`pages/frame` 的 `text_role_owned` 是同一个理由的单行版)。
//!
//! ## 快照里**没有**的东西(都登记在 `pages/mod.rs` 的偏离表里)
//!
//! | 缺什么 | 后果 | 兜法 |
//! |---|---|---|
//! | 「版本列表已加载完成」标志 | 空列表既可能是「还没拉到」也可能是「真的没有」 | [`Download::loaded`] 按「快照里有没有版本」判:没有 = 加载中 |
//! | 版本列表**失败**事件 | 拉取失败会停在加载态(不静默:页面上有说明与刷新入口) | 错误态借安装阶段的 `Failed`(结构化、可翻译) |
//! | 「当前选中」字段 | 选中态存在模型里,任何快照重建都会回到「正在安装的那个版本」 | 见 `pages/mod.rs` 偏离登记;长期要靠 presenter 加字段 |
//!
//! ## 中文串一个都不许画
//!
//! `InstallProgressView::message` / `InstallStage::label()` 都是**中文业务串**(`i18n::bridge`
//! 的规矩 2:core 的中文串只给日志和 CLI)。所以这里只搬**结构化字段**(阶段枚举、计数、字节),
//! 文案按 `Key` 现求——中文环境自然出中文,英文环境不需要任何中文字体。
use unlucky_presenter::dto::{
human_bytes, InstallProgressView, InstallStage, VersionKind, VersionSummary,
};
use unlucky_presenter::state::AppState;
use unlucky_i18n::{Catalog, Key, Lang};
use super::options::{InstallDraft, OptionsView};
/// 取消安装之后那一句提示(M105)。
///
/// 为什么文案在页面层而不是 presenter:取消的**信号**由 host 发(`CoreEvent::InstallCancelled`),
/// 但 presenter 不依赖 `unlucky-i18n`,写死的中文会和页面语言打架(复审 P2-2 抓的就是这个:
/// 选项区是英文、取消弹窗是中文)。所以文本按 `Key` 现求,语言跟着页面走。
#[must_use]
pub fn cancelled_toast(lang: Lang) -> String {
lang.catalog().text(Key::DownloadCancelled).to_owned()
}
mod category;
pub use category::Category;
/// 版本列表的一行(`ListItem` 的两行档:headline = 版本 id,supporting = `正式版 · 发布于 …`)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VersionRow {
/// 版本 id(`1.21.9` / `rd-132211`)——也是「安装」指令的载荷
pub id: String,
/// 发布日期(ISO-8601 日期部分,快照原样)
pub released: String,
/// 版本类型
pub kind: VersionKind,
/// 本地是否已安装(行上给勾 / overline `Installed`)
pub installed: bool,
/// 副文案(`正式版 · 发布于 2026-09-20`;一行 = 一个 key,不拼片段)
pub supporting: String,
}
impl VersionRow {
pub(super) fn of(version: &VersionSummary, catalog: Catalog) -> Self {
Self {
id: version.id.clone(),
released: version.released.clone(),
kind: version.kind,
installed: version.installed,
supporting: catalog.render(
Key::DownloadRowSupporting,
&[
("kind", catalog.text(kind_label_key(version.kind))),
("date", version.released.as_str()),
],
),
}
}
/// 这个版本属于哪个主版本桶(规则见 [`super::group`])。
pub fn major(&self) -> Option<super::group::MajorKey> {
super::group::key(&self.id, self.kind)
}
}
/// 版本类型 → 文案 key(远古版不分子类:chip 只有一档,行上再分子类等于两套语言)。
pub(super) const fn kind_label_key(kind: VersionKind) -> Key {
match kind {
VersionKind::Release => Key::DownloadCategoryRelease,
VersionKind::Snapshot => Key::DownloadCategorySnapshot,
VersionKind::Ancient(_) => Key::DownloadCategoryAncient,
}
}
/// 一次安装的展示态(结构化字段 + 已求好的文案)。
#[derive(Debug, Clone, PartialEq)]
pub struct InstallView {
/// 目标版本(= 选中的那一行)
pub version: String,
/// 阶段
pub stage: InstallStage,
/// 阶段词(`下载文件`)
pub stage_label: String,
/// 计数行(`128/512 文件`;未知时是「等第一个文件…」)
pub detail: String,
/// 速率 + 剩余时间(`1.2 MB/s · 剩 1m15s`;未知时 `None`)
pub rate: Option<String>,
/// 整体进度 0.0~1.0
pub ratio: f32,
/// 进度是否已知(`false` = 进度条画不确定态)
pub known: bool,
}
impl InstallView {
/// 快照 → 展示态。
pub(super) fn of(progress: &InstallProgressView, catalog: Catalog) -> Self {
let known = progress.is_known();
let detail = if known {
catalog.render(
Key::DownloadProgress,
&[
("done", &progress.files_done.to_string()),
("total", &progress.files_total.to_string()),
],
)
} else {
catalog.text(Key::DownloadProgressUnknown).to_owned()
};
let rate = if progress.speed_bps == 0 || progress.eta_secs == 0 {
None
} else {
let eta = if progress.eta_secs >= 60 {
catalog.render(
Key::DownloadEtaMins,
&[
("mins", &(progress.eta_secs / 60).to_string()),
("secs", &format!("{:02}", progress.eta_secs % 60)),
],
)
} else {
catalog.render(
Key::DownloadEtaSecs,
&[("secs", &progress.eta_secs.to_string())],
)
};
Some(catalog.render(
Key::DownloadRate,
&[
("speed", &format!("{}/s", human_bytes(progress.speed_bps))),
("eta", eta.as_str()),
],
))
};
Self {
version: progress.version.clone(),
stage: progress.stage,
stage_label: catalog.text(stage_key(progress.stage)).to_owned(),
detail,
rate,
ratio: progress.ratio(),
known,
}
}
/// 失败了(错误态要出可读文案 + 出口,不许静默)。
pub const fn failed(&self) -> bool {
matches!(self.stage, InstallStage::Failed)
}
/// 收尾了(`Done` / `Failed` / `Cancelled`)。
pub fn terminal(&self) -> bool {
self.stage.is_terminal()
}
/// **结束且没什么可说**的收尾:成功(`Done`)与取消(`Cancelled`)。
///
/// 这两种终态页面没有动作可给,留着只会让安装面板停在一个点不动的状态上(复审 p2 的根因)。
/// 失败**不在其中**:它还要给原因出口。
pub fn finished(&self) -> bool {
matches!(self.stage, InstallStage::Done | InstallStage::Cancelled)
}
/// 还在跑(「取消安装」只在这时给)。
pub fn running(&self) -> bool {
!self.stage.is_terminal()
}
}
/// 阶段 → 文案 key(**不**用 `InstallStage::label()`:那是中文业务串)。
const fn stage_key(stage: InstallStage) -> Key {
match stage {
InstallStage::Planning => Key::DownloadStagePlanning,
InstallStage::Downloading => Key::DownloadStageDownloading,
InstallStage::Installing => Key::DownloadStageInstalling,
InstallStage::Registering => Key::DownloadStageRegistering,
InstallStage::Done => Key::DownloadStageDone,
InstallStage::Failed => Key::DownloadStageFailed,
InstallStage::Cancelled => Key::DownloadStageCancelled,
}
}
/// 下载页要画的东西(**全部字段都能从快照推出来**,页面自己不留状态)。
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Download {
/// 版本列表(顺序 = 快照顺序)
pub rows: Vec<VersionRow>,
/// 当前分类(从快照反推)
pub category: Category,
/// 选中的版本 id(`None` = 没选中 → 面板只给一句提示)
pub selected: Option<String>,
/// 安装选项区(M105):选中了版本、又没在装的时候才有
pub options: Option<OptionsView>,
/// 正在跑(或刚跑完)的安装
pub install: Option<InstallView>,
/// 快照里有没有版本列表(`false` = 还没拉到 → 加载态)
pub loaded: bool,
/// 搜索词(presenter 的 `download_query` 原样;空串 = 不过滤)
pub query: String,
/// **跨分类**命中数:搜索时侧栏显示它,所以它不随当前分类变化
pub hits: usize,
/// 选中的主版本桶(`None` = 全部;第二级侧栏的选中态,纯视图态)
pub selected_major: Option<String>,
/// 类型栏被用户手动折叠(`true` = 64 图标条;宽度档自己也会折叠,两者取更窄的)
pub type_rail_folded: bool,
/// 主版本桶(第二级侧栏的数据源;由 [`Self::rows`] 聚合,所以只含**当前分类**的桶)
pub buckets: Vec<super::group::Bucket>,
}
impl Download {
/// 空模型 = 加载态(首帧,以及 `pages::Model::new` 的默认值)。
pub fn loading() -> Self {
Self::default()
}
/// presenter 快照 → 下载页模型(**唯一的真数据入口**)。
///
/// 三个过滤条件都是「过滤」,不是「乐观回显」:
/// - `installed_only` / `download_query` 是 presenter 自己的状态,页面不该另存一份;
/// - `category` 是**本页的过滤条件**(参数传进来,调用方持有它):真 host 的
/// `AppCommand::ListVersions(_)` 会丢掉分类参数、回全量清单(见 `host/dispatch.rs`),
/// 所以分类**必须在页面这一侧过滤**,否则 chip 只是点亮一下、列表照旧是全量(复审 P2-1)。
/// 过滤谓词与 chip 的选中判断是同一个 [`Category::matches`],于是「chip 说的」与「列表画的」
/// 永远是同一份真相,事件回来重建也不会跳回「全部」。
///
/// 过滤后为空就是「空态」(`loaded` 仍为 `true`)。
///
/// `draft` 是**用户还没确认的安装输入**(选中版本 + 加载器 + 加载器版本 + 实例名)。
/// 它住在 `App`(照分类筛选的既有做法),因为 `refresh_view` 每次事件都会把整棵模型重建,
/// 页面自己存不住任何东西。选中项还要**在当前列表里**才算数:切分类后那一行不在了,
/// 就不该还留着一个点不动的「安装」。
///
/// M107:`query` / `hits` / `buckets` 也走这条路——查询词来自快照的 `download_query`,
/// 桶由过滤后的行聚合(第二级侧栏的数据源)。
pub fn from_snapshot(
state: &AppState,
category: Category,
draft: &InstallDraft,
catalog: Catalog,
) -> Self {
let query = state.download_query.trim().to_ascii_lowercase();
// 过滤谓词写一次、两处用:`hits` 是**跨分类**命中数(搜索框旁边的「N 个结果」不能
// 随当前分类变,否则用户在「正式版」里搜一个只有快照才有的串会看到 0)。
let matches = |version: &VersionSummary| {
(!state.installed_only || version.installed)
&& (query.is_empty() || version.id.to_ascii_lowercase().contains(&query))
};
let hits = state
.versions
.iter()
.filter(|version| matches(version))
.count();
let rows = state
.versions
.iter()
.filter(|version| matches(version) && category.matches(version.kind))
.map(|version| VersionRow::of(version, catalog))
.collect::<Vec<_>>();
let install = state
.install
.as_ref()
.map(|view| InstallView::of(view, catalog))
// 安装**已经成功结束/被取消**就不再占用面板(复审 p2,本轮的修复点):
// presenter 只在 `InstallProgress(None)`(取消指令)时清 `AppState.install`,`Done`
// 会一直留在快照里 ⇒ 面板被一个**没有动作**的终态钉死,用户装完第一个版本就再也发不出
// 第二次安装(只能重启进程)。界面这里把「结束」当结束:面板让回列表。
//
// 为什么不连 `Failed` 一起清:失败态带着用户要的东西(停在哪个文件 + 去日志页),
// 那正是 P2-4 要它一眼可辨的理由。
.filter(|view| !view.finished());
// 选中项:安装中就是正在装的那个;否则是用户点的那个(草稿),且必须还在当前列表里。
let selected = install
.as_ref()
.map(|view| view.version.clone())
.or_else(|| draft.selected.clone())
.filter(|id| rows.iter().any(|row| &row.id == id));
// 选项区与安装面板**互斥**:在装的时候面板讲进度,讲不了选项。
let options = match &install {
Some(_) => None,
None => selected.as_ref().and_then(|id| {
rows.iter()
.find(|row| &row.id == id)
.map(|row| OptionsView::of(row, state, draft, catalog))
}),
};
Self {
// 桶由**过滤后的行**聚合:第二级侧栏只列当前分类里真实出现的版本族。
buckets: super::group::buckets(&rows),
rows,
// 分类不再从列表反推(那正是「chip 跳回全部」的来源):谁选的就是谁。
// [`Category::of`] 只留给「不经过用户选择」的入口(演示数据的首帧)。
category,
selected,
options,
install,
loaded: !state.versions.is_empty(),
// 搜索词与命中数都来自快照:页面不持有查询(见模块文档「为什么搜索也走快照」)。
query: state.download_query.trim().to_owned(),
hits,
// 主版本桶的选中是**纯视图态**(同 `selected`):快照重建会回到「全部」。
selected_major: None,
// 折叠也是纯视图态;搜索重建时由 `rebuild_after_query` 前后各搬一次。
type_rail_folded: false,
}
}
/// 选中的那一行(安装面板上的版本名)。
pub fn selected_row(&self) -> Option<&VersionRow> {
let id = self.selected.as_deref()?;
self.rows.iter().find(|row| row.id == id)
}
/// 加载态:快照里还没有版本列表。
pub const fn is_loading(&self) -> bool {
!self.loaded
}
/// 空态:列表拉到了,但过滤之后一行都不剩。
pub fn is_empty(&self) -> bool {
self.loaded && self.rows.is_empty()
}
/// 有搜索词(空串不算:用户在框里敲了空格不该触发「没有匹配」)。
pub fn has_query(&self) -> bool {
!self.query.is_empty()
}
/// **整份清单**里一个都不匹配(跨分类也没有)⇒ 画「没有匹配」而不是白屏。
pub fn no_match(&self) -> bool {
self.loaded && self.has_query() && self.hits == 0
}
/// 有命中、但**不在当前分类**里 ⇒ 画「N 个版本在别的类型下」,并给一个「看全部」的出口。
///
/// 这条与 [`Self::no_match`] 分开,是因为两者该说的话完全不同:一个是「真的没有」,
/// 一个是「有,但你站错了分类」——后者让用户多点一次 tab 就白跑一趟。
pub fn only_elsewhere(&self) -> bool {
self.loaded && self.rows.is_empty() && self.hits > 0
}
}
#[cfg(test)]
mod tests;