Merge branch 'feat/m107-dlvrail-version-management-page-dua'
This commit is contained in:
commit
c31036dea8
25 files changed
+2641
-358
No files matched your search
@@ -83,6 +83,32 @@ impl App {
|
||||
self.dispatch(AppCommand::ListVersions(kind));
|
||||
}
|
||||
}
|
||||
// M107:查询词落在快照里(无状态视图的唯一存活方式),再走同一条重建路径过滤。
|
||||
pages::Message::SetVersionQuery(query) => {
|
||||
self.rebuild_after_query(query.clone());
|
||||
}
|
||||
pages::Message::ClearVersionQuery => {
|
||||
self.rebuild_after_query(String::new());
|
||||
}
|
||||
// M107:主版本桶的选中是纯视图态,不重建、不派命令(同 `SelectVersion`)。
|
||||
//
|
||||
// **搜索期间点桶 = 「我就是要看这个桶」**(复审 r2 P2-1(b)):搜索让第二级失效(P2-1 的
|
||||
// 一致性放宽),如果这里只改选择而不清查询,用户会看到「点了没反应、清空搜索后才生效」
|
||||
// 的活控件。所以搜索中点桶顺带退出搜索,按新桶过滤——一次点击做完用户想要的两件事。
|
||||
pages::Message::SelectMajor(key) => {
|
||||
if self.view.download.has_query() {
|
||||
// 先退出搜索再落选择:`rebuild_after_query` 会用**全量行**重算桶并回填选择
|
||||
// (它带回 `selected_major`,且校验桶仍存在)。
|
||||
self.view.download.selected_major = key.clone();
|
||||
self.rebuild_after_query(String::new());
|
||||
} else {
|
||||
self.view.download.selected_major = key.clone();
|
||||
}
|
||||
}
|
||||
// M107:类型栏的手动折叠也是纯视图态。
|
||||
pages::Message::ToggleTypeRail => {
|
||||
self.view.download.type_rail_folded = !self.view.download.type_rail_folded;
|
||||
}
|
||||
pages::Message::Action(pages::Action::RefreshSources) => {
|
||||
match self.view.download.category.command() {
|
||||
Some(kind) => self.dispatch(AppCommand::ListVersions(kind)),
|
||||
@@ -151,4 +177,205 @@ impl App {
|
||||
mc_version: version,
|
||||
});
|
||||
}
|
||||
|
||||
/// 搜索词变了:写进快照 → 重建视图 → **把纯视图态捡回来**。
|
||||
///
|
||||
/// `refresh_view()` 整块换掉 `self.view`,而它只把 `category` 与**安装草稿**带回去(`app/state.rs`
|
||||
/// 归 M105/M106)。所以「折叠状态」与「选中的主版本桶」必须在重建前后自己搬一次——否则用户敲第一个
|
||||
/// 字符,展开着的类型栏就自己收起来、选中的主版本也跳回「全部」。
|
||||
/// 「搜索时侧栏保留当前位置」这条要求,落点就是这里的四行。
|
||||
fn rebuild_after_query(&mut self, query: String) {
|
||||
self.state.download_query = query;
|
||||
let major = self.view.download.selected_major.take();
|
||||
let folded = self.view.download.type_rail_folded;
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
// 回填要**校验**:桶是按当前行聚合法出来的,新桶列表里没有的那个选择不该被复活
|
||||
// (复审 P2-1(a):否则会出现「侧栏没有任何一项点亮」的幽灵选中)。
|
||||
self.view.download.selected_major = major.filter(|key| {
|
||||
self.view
|
||||
.download
|
||||
.buckets
|
||||
.iter()
|
||||
.any(|bucket| bucket.key.as_deref() == Some(key.as_str()))
|
||||
});
|
||||
self.view.download.type_rail_folded = folded;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! 搜索链路的**端到端**断言(复审 P2-5)。
|
||||
//!
|
||||
//! 为什么非要在 app 层写:这条链是 `Message → on_page → on_download → AppState.download_query
|
||||
//! → refresh_view → from_snapshot 过滤`,中间任何一环断掉,页面层测试都看不见(页面只拿到
|
||||
//! 已经过滤好的模型)。复审原话:「把 `rebuild_after_query` 第一行删掉,现有测试全绿,而搜索
|
||||
//! 变成假控件」——这几条就是补那个洞的。
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use unlucky_presenter::dto::{AppEvent, Backend, CoreEvent, VersionKind, VersionSummary};
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 只记账、不回事件的后端(同 `app/update/download_wiring.rs`):命令出了 UI 层,状态一个字节
|
||||
/// 都不许变——否则「本地乐观回显」与「事件回流」终态相同,断言会假绿。
|
||||
struct Recorder(Rc<RefCell<Vec<AppCommand>>>);
|
||||
|
||||
impl Backend for Recorder {
|
||||
fn drain_events(&mut self) -> Vec<AppEvent> {
|
||||
Vec::new()
|
||||
}
|
||||
fn dispatch(&mut self, command: AppCommand) {
|
||||
self.0.borrow_mut().push(command);
|
||||
}
|
||||
}
|
||||
|
||||
fn version(id: &str) -> VersionSummary {
|
||||
VersionSummary {
|
||||
id: id.to_string(),
|
||||
kind: VersionKind::Release,
|
||||
released: "2026-09-20".to_string(),
|
||||
installed: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个装好三行版本的 App(`1.21.9` / `1.20.4` / `1.20.1`)。
|
||||
fn app() -> App {
|
||||
let families = crate::theme::Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
let mut app = App::with_presenter(
|
||||
crate::cli::Options::default(),
|
||||
families,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(Recorder(Rc::new(RefCell::new(Vec::new())))),
|
||||
SettingsBridge::memory_only(),
|
||||
);
|
||||
app.state
|
||||
.apply(AppEvent::Core(CoreEvent::VersionListChanged(vec![
|
||||
version("1.21.9"),
|
||||
version("1.20.4"),
|
||||
version("1.20.1"),
|
||||
])));
|
||||
app.refresh_view();
|
||||
app
|
||||
}
|
||||
|
||||
/// 键入 → 快照 → 列表:一条走完的链。
|
||||
///
|
||||
/// 抓得住:`rebuild_after_query` 不再写 `AppState.download_query`(搜索变假控件)、
|
||||
/// 或 `from_snapshot` 不再按 query 过滤(列表不跟着收窄)。
|
||||
/// 抓不住:真机上 `text_input` 的按键是否走到这里(那是组件的事,`search_bar` 已接 `on_input`)。
|
||||
#[test]
|
||||
fn typing_a_query_reaches_the_state_and_narrows_the_list() {
|
||||
let mut app = app();
|
||||
assert_eq!(app.view.download.rows.len(), 3, "初始是全量");
|
||||
|
||||
let _ = app.on_page(pages::Message::SetVersionQuery("1.20".to_string()));
|
||||
|
||||
assert_eq!(app.state.download_query, "1.20", "查询词落在快照里");
|
||||
assert_eq!(app.view.download.rows.len(), 2, "列表收窄到两行");
|
||||
assert_eq!(app.view.download.hits, 2);
|
||||
assert_eq!(app.view.download.query, "1.20", "模型回显查询词");
|
||||
|
||||
// 清空回到全量(`ClearVersionQuery` 那一支)
|
||||
let _ = app.on_page(pages::Message::ClearVersionQuery);
|
||||
assert!(app.state.download_query.is_empty());
|
||||
assert_eq!(app.view.download.rows.len(), 3);
|
||||
}
|
||||
|
||||
/// 查询词**跨事件存活**:再来一次重建,列表仍是收窄的。
|
||||
///
|
||||
/// 抓得住:把查询词只写在视图模型里(下一次 `refresh_view` 就丢)——这是「搜索看起来能用、
|
||||
/// 一来事件就复位」的坏法。
|
||||
#[test]
|
||||
fn the_query_survives_a_snapshot_rebuild() {
|
||||
let mut app = app();
|
||||
let _ = app.on_page(pages::Message::SetVersionQuery("1.21".to_string()));
|
||||
assert_eq!(app.view.download.rows.len(), 1);
|
||||
|
||||
// 安装进度这类事件回来时,`pump()` 会重建视图:查询词必须还在
|
||||
app.state
|
||||
.apply(AppEvent::Core(CoreEvent::InstallProgress(None)));
|
||||
app.refresh_view();
|
||||
|
||||
assert_eq!(app.state.download_query, "1.21", "查询词在快照里,重建不丢");
|
||||
assert_eq!(app.view.download.rows.len(), 1, "列表仍收窄");
|
||||
}
|
||||
|
||||
/// **搜索期间点主版本桶 = 退出搜索并按该桶过滤**(复审 r2 P2-1(b))。
|
||||
///
|
||||
/// 抓得住:`SelectMajor` 只改选择不清查询 —— 那时搜索让第二级失效(P2-1 的一致性放宽),
|
||||
/// 用户点了当场没反应、要等清空搜索那次点击才生效,就是「可点但无反应」的活控件。
|
||||
/// 抓不住:真机点击是否走到这里(`ListItem`/`Chip` 的 `on_press` 是组件既有实现)。
|
||||
#[test]
|
||||
fn clicking_a_bucket_during_a_search_leaves_the_search() {
|
||||
let mut app = app();
|
||||
let _ = app.on_page(pages::Message::SetVersionQuery("1.20".to_string()));
|
||||
assert_eq!(app.view.download.rows.len(), 2, "此刻在搜 1.20");
|
||||
|
||||
// 点「1.21」这个桶
|
||||
let _ = app.on_page(pages::Message::SelectMajor(Some("1.21".to_string())));
|
||||
|
||||
assert!(app.state.download_query.is_empty(), "点桶顺带退出搜索");
|
||||
assert!(!app.view.download.has_query());
|
||||
assert_eq!(
|
||||
app.view.download.selected_major.as_deref(),
|
||||
Some("1.21"),
|
||||
"选择落在刚点的那个桶上"
|
||||
);
|
||||
assert_eq!(
|
||||
app.view.download.rows.len(),
|
||||
3,
|
||||
"查询词清掉之后模型回到全量(桶过滤发生在渲染时的 visible 里)"
|
||||
);
|
||||
assert_eq!(
|
||||
app.view
|
||||
.download
|
||||
.buckets
|
||||
.iter()
|
||||
.find(|bucket| bucket.key.as_deref() == Some("1.21"))
|
||||
.map(|bucket| bucket.count),
|
||||
Some(1),
|
||||
"1.21 桶里只有一个版本 ⇒ 列表会显示一行"
|
||||
);
|
||||
|
||||
// 没有搜索词时点桶照旧只改选择(不触发多余重建)
|
||||
let _ = app.on_page(pages::Message::SelectMajor(None));
|
||||
assert_eq!(app.view.download.selected_major, None);
|
||||
}
|
||||
|
||||
/// 搜索时侧栏的**位置与折叠**不许丢(`rebuild_after_query` 的三行)。
|
||||
///
|
||||
/// 抓得住:删掉那两行回填(敲第一个字符,类型栏自己弹开、主版本跳回「全部」)。
|
||||
/// 抓不住:其它事件(安装进度)回来时也会复位 —— 那条要 `app/state.rs` 的 `refresh_view`
|
||||
/// 一起改,不在本 mission scope,已在报告里明确登记「留到 M105 之后」。
|
||||
#[test]
|
||||
fn a_query_keeps_the_rail_position_and_the_fold() {
|
||||
let mut app = app();
|
||||
app.view.download.selected_major = Some("1.20".to_string());
|
||||
app.view.download.type_rail_folded = true;
|
||||
|
||||
let _ = app.on_page(pages::Message::SetVersionQuery("1.20".to_string()));
|
||||
|
||||
assert_eq!(
|
||||
app.view.download.selected_major.as_deref(),
|
||||
Some("1.20"),
|
||||
"桶选择还在(生效值在搜索期间放宽,但选择本身不许被清)"
|
||||
);
|
||||
assert!(app.view.download.type_rail_folded, "折叠状态还在");
|
||||
|
||||
// 选中一个**新桶列表里不存在**的桶:搜索后不许把它复活成幽灵选中(P2-1(a))
|
||||
app.view.download.selected_major = Some("9.99".to_string());
|
||||
let _ = app.on_page(pages::Message::SetVersionQuery("1.20".to_string()));
|
||||
assert_eq!(
|
||||
app.view.download.selected_major, None,
|
||||
"新桶列表里没有 9.99 ⇒ 回填空值,而不是留一个点不亮的幽灵选中"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -63,10 +63,16 @@ impl App {
|
||||
pages::Message::SelectVersion(_)
|
||||
| pages::Message::InstallVersion(_)
|
||||
| pages::Message::CancelInstall
|
||||
// M105 的三支 + M107 的四支 + 分类:全部由 `on_download` 提前消化(显式列出保住穷尽性)
|
||||
| pages::Message::SetLoader(_)
|
||||
| pages::Message::SetLoaderVersion(_)
|
||||
| pages::Message::SetInstanceName(_)
|
||||
| pages::Message::SetCategory(_) => debug_assert!(false, "应由 on_download 消费"),
|
||||
| pages::Message::SetCategory(_)
|
||||
// M107 的四支同属下载页(查询 / 清空 / 主版本桶 / 折叠类型栏)
|
||||
| pages::Message::SetVersionQuery(_)
|
||||
| pages::Message::ClearVersionQuery
|
||||
| pages::Message::SelectMajor(_)
|
||||
| pages::Message::ToggleTypeRail => debug_assert!(false, "应由 on_download 消费"),
|
||||
// M95:清空日志是**呈现层**操作(`log_lines` 是 presenter 的公开快照字段,
|
||||
// 不派业务命令、不写持久状态)
|
||||
pages::Message::Action(pages::Action::ClearLogs) => {
|
||||
|
||||
@@ -1,39 +1,32 @@
|
||||
//! 下载页:**单列**版面的用例(页面块吃满页宽,散文才受 424 行宽约束)。
|
||||
//! **版本管理页**(原下载页):三栏版面 —— 版本类型 / 主版本 / 列表 + 安装面板。
|
||||
//!
|
||||
//! 这一页永远单列,理由不是「简单」,而是官方对「一次只做一件事」的入口(下载队列)的口径:
|
||||
//! 内容是一串线性进度项,分栏没有第二个阅读对象。
|
||||
//!
|
||||
//! **M102 订正**:旧版把整页块压到 424 并水平居中(1180 宽下左缘 422,页头标题在 112,
|
||||
//! 用户令(信息架构调整)把这一页从「下载」改称「版本管理」:下载页只负责整合包与 Mod/光影,
|
||||
//! 而这一页管的是**游戏版本与加载器安装**。本文件是那次改版后的页面装配点。
|
||||
//! **M102 版面订正**:旧版把整页块压到 424 并水平居中(1180 宽下左缘 422、页头标题在 112,
|
||||
//! 左右各空 ~300px,视觉复审判为「内容缩在中间一条窄栏」)。现在页面块走 [`frame::body`]
|
||||
//! ——左缘与页头标题同一条线(= 页边距 24),宽度 = 可用宽度(1180 下 1044)。
|
||||
//! 424 只约束**成段散文**(本页没有,散文在实例详情页),列表行 / Tabs / 分隔线吃满页宽。
|
||||
//! ——左缘与页头标题同一条线(= 页边距 24),宽 = 块宽 `frame::body_width`(1180 下 1044,
|
||||
//! 极宽窗封顶 **1440**)。424 只约束**成段散文**(本页没有),三栏的每一栏都吃块宽。
|
||||
//!
|
||||
//! ## M94:占位控件 → 真组件 + 真数据 + 真指令(逐项对照)
|
||||
//! ## 版面:三栏,宽度由布局系统裁决
|
||||
//!
|
||||
//! | 位置 | M84(占位) | M94(真组件) |
|
||||
//! |---|---|---|
|
||||
//! | 页头动作 | `frame::text_action`(`text` + 手搓 hover) | `buttons::text`(五式 + 状态层) |
|
||||
//! | 分类 | 无 | `navigation::tabs`(Primary,48 高 / 3px `Primary` 指示条) |
|
||||
//! | 列表行 | 无 | `navigation::list_item`(两行档 **60**:`row_height` 44 + 每多一行 16) |
|
||||
//! | 分隔线 | `frame::divider` | `components::divider::full` / `list` |
|
||||
//! | 安装按钮 | 无 | `buttons::filled`(**本页唯一实心 accent**,rubric K2) |
|
||||
//! | 进度 | 无 | `feedback::progress::linear`(已知 = determinate,未知 = indeterminate) |
|
||||
//! | 空态 | `frame::empty_state` | `feedback::empty_state`(`EmptyState` 真组件) |
|
||||
//! | 数据 | `"3 sources healthy · …"` 硬编码假状态行 | [`Download`](`app/view.rs` 从 presenter 快照转) |
|
||||
//! | 文案 | 英文字面量 | `unlucky_i18n` 的 `Key`(拼错编译失败;中英两套目录) |
|
||||
//! ```text
|
||||
//! ┌─────────┬─────────┬──────────────────────────────┐
|
||||
//! │ 版本类型 │ 主版本 │ 搜索条 · 命中数 · 计数头 │
|
||||
//! │ 140/64 │ 140/64 │ 版本行(单行四项) │
|
||||
//! │ │ │ … │
|
||||
//! │ │ │ 安装面板(M105 的选项区在这里)│
|
||||
//! └─────────┴─────────┴──────────────────────────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! ## 状态矩阵(四个状态都能从快照推出来,都出图)
|
||||
//! 三个数字**没有一个是在本文件里拍的**:它们来自
|
||||
//! [`VersionRails`](super::layout::VersionRails)(宽档裁决 + 数值表在 `pages/layout/rails.rs`)。
|
||||
//! 页面只做一件事:把裁决结果交给 [`rails`] 画出来。
|
||||
//!
|
||||
//! | 状态 | 条件 | 组件 |
|
||||
//! |---|---|---|
|
||||
//! | 加载 | 快照里没有版本列表([`Download::is_loading`]) | 标题 + 说明,**居中、无进度形状**([`states::loading`]) |
|
||||
//! | 空 | 列表拉到了、过滤后为空([`Download::is_empty`]) | `EmptyState` + 刷新 CTA,**同样居中**([`states::empty`]) |
|
||||
//! | 有数据 | 有版本行 | Tabs + 计数头 + `ListItem` 列表([`rows`]) |
|
||||
//! | 安装中 | `AppState::install` 在跑 | 阶段 + **进度条(仅当进度已知)** + 计数 + 「取消」([`install`]) |
|
||||
//! | 失败 | `install.stage == Failed` | **`ErrorContainer` 面板**:`Error` 图标/标题 + 实心「打开日志」,**没有进度条**([`install`]) |
|
||||
//! ## 原顶部 4 个 tab 的去处(不是新造分类)
|
||||
//!
|
||||
//! 两个占位状态(加载 / 空)走**同一套规则**:内容在可用区居中(依据:`feedback::empty_state`
|
||||
//! 的既有口径 `12b §15.4`,且两态互切时内容不会纵向跳)。
|
||||
//! 四个 chip(全部 / 正式版 / 预览版 / 远古版)**语义一字不改**,只是从顶部横排搬到第一栏竖排;
|
||||
//! 窄到三栏不成立时(可用宽 < 600)它们**原样回到顶部**([`rails::stacked_chips`])——同一条
|
||||
//! [`Category`] 谓词、同一条 `AppCommand::ListVersions`。
|
||||
//!
|
||||
//! ## 意图 → 消息 → 指令(页面只发意图,不做乐观回显)
|
||||
//!
|
||||
@@ -57,17 +50,52 @@
|
||||
//! 进度条只读 `model.install`:它由 presenter 的 `InstallProgress` 事件经 `apply` 灌进来。
|
||||
//! 于是「点了没变」只有一种解释——后端还没回事件,这正是要的(P3-2 的纪律)。
|
||||
//!
|
||||
//! ## 版面判据(M102 修订了「页头 vs 正文」那一条)
|
||||
//! ## M107:两级侧栏从哪来(第一级 = 版本类型,第二级 = 主版本桶)
|
||||
//!
|
||||
//! 正文块左缘 = 页头标题左缘 = `rail 88 + gutter 24 = 112`([`frame::body`]);
|
||||
//! 断点 600 / 840、rail 88、散文行宽 ≤424([`super::layout::measure_cap_px`])都由
|
||||
//! [`super::layout`] 与 `shell` 无条件施加。视觉复审偏离 #1(页头铺满、不与正文列对齐)
|
||||
//! 因此**收口**:页头与正文块现在共用同一条左缘,右缘也都停在页边距上。
|
||||
//! 第一级 = 版本类型(4 项,固定);第二级 = 主版本桶(由快照里的版本 id 按 [`group`] 的规则归纳,
|
||||
//! 远古版这类「没有主版本概念」的只有「全部版本」一项)。
|
||||
//!
|
||||
//! ## 搜索(三层口径)
|
||||
//!
|
||||
//! | 情形 | 画面 |
|
||||
//! |---|---|
|
||||
//! | 有命中 | 列表照常;侧栏**位置不动**;搜索条右侧给「N 个匹配」(跨类型读数) |
|
||||
//! | 一个都不命中 | [`states::no_match`]:没有匹配 + 换短片段 + 清空 |
|
||||
//! | 有命中但不在当前类型 | [`states::elsewhere`]:N 个在别的类型 + 「看全部类型」 |
|
||||
//! | 搜索中点第二级桶 | **退出搜索 + 按那个桶过滤**(点桶 = 「我就是要看这个桶」,见 `on_download`) |
|
||||
//!
|
||||
//! 查询词写在 presenter 快照的 `download_query` 里(页面无状态,见 [`model::Download::from_snapshot`]),
|
||||
//! 所以「搜索 → 任何事件回来之后仍然在搜」这条不需要页面持有任何字段。
|
||||
//!
|
||||
//! ## 状态矩阵(加载 / 空 / 有数据 / 安装中 / 失败)
|
||||
//!
|
||||
//! 与 M94 时一致,唯一的改动是**空态与搜索态都留在三栏骨架里**:侧栏与搜索框照旧在,
|
||||
//! 只有列表区换成占位内容——白屏会让人以为列表坏了。
|
||||
//!
|
||||
//! ## 两个纯视图态的字段(快照里没有,重建会丢)
|
||||
//!
|
||||
//! `selected_major`(第二级侧栏选中)与 `type_rail_folded`(类型栏手动折叠)**不在 presenter 快照里**
|
||||
//! (`AppState` 没有这两个字段,加字段要动 `pages/mod.rs` 的偏离表与 presenter,见 M107 报告)。
|
||||
//! 所以它们与既有的 `selected` 同一档处理:`on_download` 直接改视图模型,**不重建**;
|
||||
//! 唯一会重建的路径是搜索(`rebuild_after_query`),那里把两个字段在重建前后各搬一次。
|
||||
//! 其余事件(安装进度、版本列表刷新)回来时这两个字段会回到默认值——与 `selected` 的既有缺口同源,
|
||||
//! 长期要靠 presenter 加字段,已登记在报告里。
|
||||
//!
|
||||
//! ## 版面判据(M102 口径:页面块 vs 散文列)
|
||||
//!
|
||||
//! 页面仍是 `PaneKit::Single`(页内自己切三栏)。整页内容走 [`frame::body`]:块左缘 =
|
||||
//! 页头标题左缘 = `rail 88 + gutter 24 = 112`,块宽 = [`frame::body_width`](pane 宽封顶
|
||||
//! **1440**)——视觉复审 ①(内容缩在中间一条窄栏、与页头错位)因此收口。
|
||||
//! **三栏的列宽从「块宽」算**([`VersionRails::of_metrics`] 读 `frame::body_width`,不是
|
||||
//! `metrics.available`):否则 1576+ 的超宽窗上三列之和会超出 1440 的块、右侧被裁。
|
||||
//! 424([`super::layout::measure_cap_px`])在本页只对**成段散文**有效——这一页没有散文。
|
||||
|
||||
mod demo;
|
||||
mod group;
|
||||
mod install;
|
||||
mod model;
|
||||
mod options;
|
||||
mod rails;
|
||||
mod rows;
|
||||
mod states;
|
||||
|
||||
@@ -90,7 +118,7 @@ use crate::app::components::divider;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::frame::{self, Header};
|
||||
use super::layout::FrameMetrics;
|
||||
use super::layout::{FrameMetrics, VersionRails};
|
||||
use super::{Action, Message};
|
||||
|
||||
/// 整页(**唯一**的真数据入口:`model` 由 `app/view.rs` 从 presenter 快照建)。
|
||||
@@ -118,24 +146,18 @@ pub fn view<'a>(
|
||||
frame::page(ui, metrics, header, body(ui, metrics, catalog, model))
|
||||
}
|
||||
|
||||
/// 正文:加载 / 空 / 列表 + 底部安装面板(列宽由 [`super::layout`] 决议,页面只吃结果)。
|
||||
/// 正文:加载态仍是居中占位;其余状态都长在三栏骨架上。
|
||||
fn body<'a>(
|
||||
ui: &'a Ui,
|
||||
metrics: FrameMetrics,
|
||||
catalog: Catalog,
|
||||
model: &'a Download,
|
||||
) -> Element<'a, Message> {
|
||||
if model.is_loading() || model.is_empty() {
|
||||
let placeholder = if model.is_loading() {
|
||||
states::loading(ui, catalog)
|
||||
} else {
|
||||
states::empty(ui, catalog)
|
||||
};
|
||||
// 占位状态下**也**把安装面板带上(复审 p3):列表空/还没到,不代表安装停了——
|
||||
// 没有这一段的话,安装进行中一旦列表被过滤空,进度条会凭空消失。
|
||||
if model.is_loading() {
|
||||
// 还没拉到版本列表:三栏没有内容可填,给占位(安装面板照旧带着,见 M94 复审 p3)
|
||||
let content: Element<'a, Message> = if model.install.is_some() {
|
||||
column![
|
||||
placeholder,
|
||||
states::loading(ui, catalog),
|
||||
divider::full(ui),
|
||||
install::panel(ui, catalog, model)
|
||||
]
|
||||
@@ -144,23 +166,37 @@ fn body<'a>(
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
} else {
|
||||
placeholder
|
||||
states::loading(ui, catalog)
|
||||
};
|
||||
return frame::body(metrics, content);
|
||||
}
|
||||
|
||||
let content: Element<'a, Message> = column![
|
||||
rows::categories(ui, catalog, model.category),
|
||||
rows::head(ui, catalog, model),
|
||||
divider::full(ui),
|
||||
rows::list(ui, catalog, model),
|
||||
divider::full(ui),
|
||||
install::panel(ui, catalog, model),
|
||||
]
|
||||
.spacing(ui.space(Space::X3))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into();
|
||||
// 宽度裁决:档位来自布局系统,「用户手动折叠了类型栏」这一条覆盖叠在上面。
|
||||
let rails = {
|
||||
let rails = VersionRails::of_metrics(metrics);
|
||||
if model.type_rail_folded {
|
||||
rails.with_type_folded()
|
||||
} else {
|
||||
rails
|
||||
}
|
||||
};
|
||||
|
||||
let content: Element<'a, Message> = if rails.tier.is_three_column() {
|
||||
rails::three_column(ui, catalog, model, rails)
|
||||
} else {
|
||||
// 三栏不成立(可用宽 < 600):类型与主版本原样退回顶部 chips,列表吃掉整条可用宽
|
||||
column![
|
||||
rails::stacked_chips(ui, catalog, model),
|
||||
divider::full(ui),
|
||||
rows::content(ui, catalog, model),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
};
|
||||
|
||||
// M102:整页块走 `frame::body`(贴左、块宽封顶 1440),与页头标题同一条左缘。
|
||||
// 三栏都在这个块里切,所以 `VersionRails` 读的是块宽而不是 `metrics.available`。
|
||||
frame::body(metrics, content)
|
||||
}
|
||||
@@ -20,7 +20,12 @@
|
||||
//!
|
||||
//! 取值:`ready`(缺省)/ `loading` / `empty` / `selected` / `category-release` / `installing` /
|
||||
//! `installing-late` / `installing-empty` / `failed` / `done` / `done-next` /
|
||||
//! **`options` / `options-fabric` / `options-incompat`**(M105 的安装选项区三态)。
|
||||
//! **`options` / `options-fabric` / `options-incompat`**(M105 的安装选项区三态)/
|
||||
//! **`category-ancient` / `search` / `search-major` / `search-empty` / `search-elsewhere`**
|
||||
//! (M107 的两级侧栏与搜索取样点)。
|
||||
//!
|
||||
//! M107 那几个取样点**都走产品那条真转换**(`Download::from_snapshot`,查询词写在快照的
|
||||
//! `download_query` 里):图证明的是产品路径的行为,不是演示的特权。
|
||||
//!
|
||||
//! `category-release` / `done` / `done-next` 都**不手搓结果**,而是拿一份 `AppState` 跑
|
||||
//! [`Download::from_snapshot`]:图证明的是产品路径的行为(分类本地过滤、装完后面板让回列表),
|
||||
@@ -92,6 +97,19 @@ pub fn demo(catalog: Catalog) -> Download {
|
||||
// 安装进行中、但列表被过滤空:面板要跟着占位态一起在(复审 p3 的边角)
|
||||
// 分类筛选(本页本地过滤:真 host 丢弃 ListVersions 的分类参数)——混合清单里只留正式版
|
||||
Some("category-release") => category(catalog, Category::Release),
|
||||
// M107 的两级侧栏 + 搜索:这三种状态**都走产品那条真转换**(from_snapshot),
|
||||
// 图里看到的是页面真会画的东西,不是演示的特权。
|
||||
Some("category-ancient") => searched(catalog, Category::Ancient, ""),
|
||||
Some("search") => searched(catalog, Category::All, "1.20"),
|
||||
Some("search-major") => majored(searched(catalog, Category::All, ""), "1.21"),
|
||||
Some("search-empty") => searched(catalog, Category::All, "zzz"),
|
||||
Some("search-elsewhere") => searched(catalog, Category::Release, "24w"),
|
||||
// 复审 P2-1 的取样点:**选中 1.21 桶的同时搜 1.20**。修好之后列表里是 2 行命中、
|
||||
// 侧栏点亮「全部版本」(第二级在搜索期间放宽),不会出现「2 个匹配 + 没有版本」。
|
||||
Some("search-over-bucket") => majored(searched(catalog, Category::All, "1.20"), "1.21"),
|
||||
// 复审 P2-2 的取样点:**22 个主版本桶**(MOJANG 清单里 `Release` 就是这个量级)。
|
||||
// chips 档一行放不下 ⇒ 主版本行必须能横向滚,否则后面的桶永远点不到。
|
||||
Some("many-buckets") => many(catalog),
|
||||
Some("installing-empty") => Download {
|
||||
install: Some(install(InstallStage::Downloading, 0.42, catalog)),
|
||||
..Download::loading()
|
||||
@@ -100,7 +118,7 @@ pub fn demo(catalog: Catalog) -> Download {
|
||||
}
|
||||
}
|
||||
|
||||
/// 有数据:六行(三条正式、一条预览、两条远古),与 presenter 的 `mock_versions` 同形。
|
||||
/// 有数据:八行(四条正式、两条预览、两条远古),覆盖四种版本形态与三个主版本桶。
|
||||
///
|
||||
/// 不复用 `mock_data::mock_versions()`:演示数据是**出图的基线**,得比 mock 稳定
|
||||
/// (mock 随 P3 的假数据一起漂)。
|
||||
@@ -114,11 +132,16 @@ fn ready(catalog: Catalog) -> Download {
|
||||
Download {
|
||||
// 混合分类 → 没有单个 chip 该亮(这正是 `Category::of` 的反推结果)
|
||||
category: Category::of(&versions),
|
||||
buckets: super::group::buckets(&rows),
|
||||
hits: rows.len(),
|
||||
rows,
|
||||
selected: None,
|
||||
options: None,
|
||||
install: None,
|
||||
loaded: true,
|
||||
query: String::new(),
|
||||
selected_major: None,
|
||||
type_rail_folded: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +169,27 @@ fn options(catalog: Catalog, version: &str, loader: Loader, cached: Option<Vec<&
|
||||
Download::from_snapshot(&state, Category::All, &draft, catalog)
|
||||
}
|
||||
|
||||
/// 22 个主版本桶(`1.21 … 1.0`)的清单:chips 档的横向滚动取证用。
|
||||
fn many(catalog: Catalog) -> Download {
|
||||
let versions = (0..=21)
|
||||
.rev()
|
||||
.map(|minor| VersionSummary {
|
||||
id: format!("1.{minor}.4"),
|
||||
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)
|
||||
}
|
||||
|
||||
/// 演示用的版本表(`ready` 与 `finished` 共用;与 presenter 的 `mock_versions` 同形)。
|
||||
///
|
||||
/// 四种形态各有样本:`1.21.9`(正式)、`1.21.9-pre3`(预发布)、`24w14a`(周更)、
|
||||
/// `rd-132211` / `a1.2.6`(远古,没有主版本)。
|
||||
fn demo_versions() -> Vec<VersionSummary> {
|
||||
fn summary(id: &str, kind: VersionKind, released: &str, installed: bool) -> VersionSummary {
|
||||
VersionSummary {
|
||||
@@ -161,7 +204,9 @@ fn demo_versions() -> Vec<VersionSummary> {
|
||||
summary("1.21.9", VersionKind::Release, "2026-09-20", false),
|
||||
summary("1.21.8", VersionKind::Release, "2026-07-11", true),
|
||||
summary("1.20.4", VersionKind::Release, "2023-12-07", true),
|
||||
summary("1.20.1", VersionKind::Release, "2023-06-12", false),
|
||||
summary("1.21.9-pre3", VersionKind::Snapshot, "2026-09-01", false),
|
||||
summary("24w14a", VersionKind::Snapshot, "2024-04-03", false),
|
||||
summary(
|
||||
"rd-132211",
|
||||
VersionKind::Ancient(AncientKind::Classic),
|
||||
@@ -214,6 +259,22 @@ fn category(catalog: Catalog, wanted: Category) -> Download {
|
||||
Download::from_snapshot(&state, wanted, &InstallDraft::default(), catalog)
|
||||
}
|
||||
|
||||
/// 「用户在搜索框里敲了几个字符」的演示:查询词写进**快照字段**(产品路径就是这么活的)。
|
||||
fn searched(catalog: Catalog, wanted: Category, query: &str) -> Download {
|
||||
let mut state = AppState::new();
|
||||
state.apply(AppEvent::Core(CoreEvent::VersionListChanged(
|
||||
demo_versions(),
|
||||
)));
|
||||
state.download_query = query.to_string();
|
||||
Download::from_snapshot(&state, wanted, &InstallDraft::default(), catalog)
|
||||
}
|
||||
|
||||
/// 「用户点了第二级侧栏里的一个主版本桶」的演示(纯视图态,与 `on_download` 的落点一致)。
|
||||
fn majored(mut model: Download, key: &str) -> Download {
|
||||
model.selected_major = Some(key.to_string());
|
||||
model
|
||||
}
|
||||
|
||||
/// 一次安装的演示快照(文件计数 / 字节 / 速度 / 剩余时间都给真值,进度条画得出来)。
|
||||
fn install(stage: InstallStage, ratio: f32, catalog: Catalog) -> InstallView {
|
||||
let total = 512;
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
//! 主版本分组:把一串版本 id 归成「大版本」桶(第二级侧栏的数据源)。
|
||||
//!
|
||||
//! ## 为什么不能按第一个 `.` 切
|
||||
//!
|
||||
//! 真实的版本清单里同时存在**四种形态**(拿真样本说,不用「第几个点」这种相对说法):
|
||||
//!
|
||||
//! | 样本 | 是什么 | `split('.')` 会得到什么 | 正确的主版本 |
|
||||
//! |---|---|---|---|
|
||||
//! | `1.21.4` | 正式版 | `["1", "21", "4"]` | `1.21` |
|
||||
//! | `1.21.9-pre3` | 预发布(正式版的兄弟) | `["1", "21", "9-pre3"]` | `1.21` |
|
||||
//! | `24w14a` | 周更快照 | `["24w14a"]`(一个都没有) | `24w`(周更的年桶) |
|
||||
//! | `rd-132211` / `a1.2.6` | 远古版(Classic / Alpha) | `["rd-132211"]` / `["a1", "2", "6"]` | **没有主版本** |
|
||||
//!
|
||||
//! 「按第一个 `.` 切」在 `1.21.4` 上给出 `1`,把 1.7 到 1.21 的全部正式版塞进一个桶;
|
||||
//! 在 `a1.2.6` 上给出 `a1`,凭空造出一个「主版本」——远古版根本没有 major.minor 这层概念。
|
||||
//!
|
||||
//! ## 规则(三条,按顺序命中即返回)
|
||||
//!
|
||||
//! 1. **带点的数值前缀**(正式版 / 预发布):id 在第一个 `-` 之前的部分,前两段都是纯数字
|
||||
//! ⇒ 主版本 = `第一段.第二段`。`1.21.9-pre3` 先切掉 `-pre3` 再取 `1.21`。
|
||||
//! 2. **周更快照**:id 形如 `24w14a`(两位年 + `w` + 两位周 + 一个修订字母)⇒ 主版本 = `24w`。
|
||||
//! 周更版本每年几十个,年桶是它的「大版本」等价物;不按周切是因为一周通常只有 1 个版本,
|
||||
//! 按周分组等于每个桶一个版本(侧栏退化成重复的列表)。
|
||||
//! 3. **其余**:返回 [`None`](没有主版本概念)。远古版(`rd-132211` / `a1.2.6` / `c0.30_01c`)
|
||||
//! 走这一条 ⇒ 第二级侧栏只有一个「全部」项。
|
||||
//!
|
||||
//! 判据是**形态**(有没有 `-` 后缀、前两段是不是纯数字、有没有 `NNwNNx` 的形状),
|
||||
//! 不是「版本号大小」,所以新增一个 `1.22.0` 或 `25w01a` 不需要改规则。
|
||||
|
||||
use unlucky_presenter::dto::VersionKind;
|
||||
|
||||
/// 主版本桶的一个标签(`1.21` / `24w`);没有主版本概念时是 [`None`]。
|
||||
pub type MajorKey = String;
|
||||
|
||||
/// 这个版本属于哪个主版本桶(规则见模块文档)。
|
||||
///
|
||||
/// 远古版**直接**返回 [`None`]:它们的 id 形态(`a1.2.6`)与正式版同族,靠形态规则会误判,
|
||||
/// 所以先按 `kind` 分流——「有没有主版本」是**版本类型**的属性,不是 id 字符串的属性。
|
||||
#[must_use]
|
||||
pub fn key(id: &str, kind: VersionKind) -> Option<MajorKey> {
|
||||
if kind.is_ancient() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 规则 1:切掉 `-pre3` / `-rc1` 这类后缀,再取前两段数值
|
||||
let head = id.split('-').next().unwrap_or(id);
|
||||
let mut parts = head.split('.');
|
||||
if let (Some(major), Some(minor)) = (parts.next(), parts.next()) {
|
||||
if is_number(major) && is_number(minor) {
|
||||
return Some(format!("{major}.{minor}"));
|
||||
}
|
||||
}
|
||||
|
||||
// 规则 2:周更快照 `24w14a` → `24w`
|
||||
if let Some((year, tail)) = head.split_once('w') {
|
||||
let week_like = tail.chars().next().is_some_and(|ch| ch.is_ascii_digit());
|
||||
if year.len() == 2 && is_number(year) && week_like {
|
||||
return Some(format!("{year}w"));
|
||||
}
|
||||
}
|
||||
|
||||
// 规则 3:没有主版本概念
|
||||
None
|
||||
}
|
||||
|
||||
/// 纯数字(空串不算)。
|
||||
fn is_number(text: &str) -> bool {
|
||||
!text.is_empty() && text.bytes().all(|byte| byte.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// 两条主版本标签的**版本序**(降序用):`1.21 > 1.9`、`24w > 14w`,数字按数值比、字母按字节比。
|
||||
///
|
||||
/// 为什么不是字符串序:`"1.9" > "1.21"` 在字典序里成立(`9 > 2`),而版本序里 1.21 更新。
|
||||
/// 逐段比:连续数字读成整数、其余字符逐字节比。
|
||||
#[must_use]
|
||||
pub fn compare(left: &str, right: &str) -> std::cmp::Ordering {
|
||||
let mut left_rest = left;
|
||||
let mut right_rest = right;
|
||||
|
||||
loop {
|
||||
match (left_rest.chars().next(), right_rest.chars().next()) {
|
||||
(None, None) => return std::cmp::Ordering::Equal,
|
||||
(None, Some(_)) => return std::cmp::Ordering::Less,
|
||||
(Some(_), None) => return std::cmp::Ordering::Greater,
|
||||
(Some(left_char), Some(right_char)) => {
|
||||
if left_char.is_ascii_digit() && right_char.is_ascii_digit() {
|
||||
let (left_number, left_tail) = take_number(left_rest);
|
||||
let (right_number, right_tail) = take_number(right_rest);
|
||||
match left_number.cmp(&right_number) {
|
||||
std::cmp::Ordering::Equal => {
|
||||
left_rest = left_tail;
|
||||
right_rest = right_tail;
|
||||
}
|
||||
other => return other,
|
||||
}
|
||||
} else if left_char == right_char {
|
||||
left_rest = &left_rest[left_char.len_utf8()..];
|
||||
right_rest = &right_rest[right_char.len_utf8()..];
|
||||
} else {
|
||||
return left_char.cmp(&right_char);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 取出开头的连续数字(返回数值与剩余部分)。
|
||||
fn take_number(text: &str) -> (u64, &str) {
|
||||
let end = text
|
||||
.find(|ch: char| !ch.is_ascii_digit())
|
||||
.unwrap_or(text.len());
|
||||
(text[..end].parse().unwrap_or(0), &text[end..])
|
||||
}
|
||||
|
||||
/// 一个主版本桶(第二级侧栏的一行):桶标签 + 桶里有多少个版本。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Bucket {
|
||||
/// 桶标签(`1.21` / `24w`);[`None`] = 这一组版本没有主版本概念(远古版)。
|
||||
pub key: Option<MajorKey>,
|
||||
/// 桶里的版本数(列表列与命中数都读它)。
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
/// 把版本行归成桶:**按版本序降序**(`1.21` 在 `1.20` 前面),无主版本概念的桶排最后。
|
||||
///
|
||||
/// 只做聚合与排序,不决定「当前选中哪个桶」——那是页面状态,住在 `pages/Download` 里。
|
||||
#[must_use]
|
||||
pub fn buckets(rows: &[super::model::VersionRow]) -> Vec<Bucket> {
|
||||
let mut grouped: Vec<Bucket> = Vec::new();
|
||||
for row in rows {
|
||||
let key = row.major();
|
||||
match grouped.iter_mut().find(|bucket| bucket.key == key) {
|
||||
Some(bucket) => bucket.count += 1,
|
||||
None => grouped.push(Bucket { key, count: 1 }),
|
||||
}
|
||||
}
|
||||
|
||||
grouped.sort_by(|left, right| match (&left.key, &right.key) {
|
||||
(Some(a), Some(b)) => compare(b, a),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => std::cmp::Ordering::Equal,
|
||||
});
|
||||
grouped
|
||||
}
|
||||
|
||||
/// 第二级侧栏此刻**真正生效**的桶(P2-1 的口径)。
|
||||
///
|
||||
/// 搜索时一律放宽到「全部」([`None`]):命中数 `hits` 是**跨桶**读数,若列表还按桶过滤,
|
||||
/// 就会出现「N matches + 0 versions + 没有版本」的自相矛盾一屏(复审 P2-1)。
|
||||
/// 放宽之后「列表行数 = 当前分类里的命中数」,侧栏也不会有「选中项不在桶列表里」的幽灵态。
|
||||
/// `selected_major` 一直留在模型里,只是搜索期间不生效——清空搜索后原来的桶自动回来。
|
||||
#[must_use]
|
||||
pub fn active_bucket(model: &super::model::Download) -> Option<&str> {
|
||||
if model.has_query() {
|
||||
None
|
||||
} else {
|
||||
model.selected_major.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// 第二级侧栏里这一项该不该点亮(`None` = 「全部版本」那一项)。
|
||||
#[must_use]
|
||||
pub fn bucket_selected(model: &super::model::Download, key: Option<&str>) -> bool {
|
||||
active_bucket(model) == key
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,104 @@
|
||||
//! 分组规则的判据:**用真实版本样本**钉住(不用「第几个点」这种相对说法,踩坑 T1)。
|
||||
//!
|
||||
//! 每条都写清抓得住什么 / 抓不住什么。
|
||||
|
||||
use super::*;
|
||||
use unlucky_presenter::dto::{AncientKind, VersionKind};
|
||||
|
||||
const RELEASE: VersionKind = VersionKind::Release;
|
||||
const SNAPSHOT: VersionKind = VersionKind::Snapshot;
|
||||
|
||||
fn ancient(kind: AncientKind) -> VersionKind {
|
||||
VersionKind::Ancient(kind)
|
||||
}
|
||||
|
||||
/// 四种形态**同时存在**时各自归到哪个桶(这是模块文档那张表的产品化版本)。
|
||||
///
|
||||
/// 抓得住:有人改回「按第一个 `.` 切」——`1.21.4` 会变成 `1`、`a1.2.6` 会变成一个假主版本。
|
||||
/// 抓不住:真实清单里还没出现的形态(比如将来的 `1.22.0`,它走同一条数值规则所以不会红)。
|
||||
#[test]
|
||||
fn the_four_shapes_land_in_the_documented_buckets() {
|
||||
// 1. 正式版
|
||||
assert_eq!(key("1.21.4", RELEASE).as_deref(), Some("1.21"));
|
||||
assert_eq!(key("1.9.4", RELEASE).as_deref(), Some("1.9"));
|
||||
assert_eq!(key("1.7.10", RELEASE).as_deref(), Some("1.7"));
|
||||
// 2. 预发布:切掉 `-pre3` 之后与它的正式版同一个桶
|
||||
assert_eq!(key("1.21.9-pre3", SNAPSHOT).as_deref(), Some("1.21"));
|
||||
assert_eq!(key("1.21-rc1", SNAPSHOT).as_deref(), Some("1.21"));
|
||||
assert_eq!(
|
||||
key("1.21.9-pre3", SNAPSHOT),
|
||||
key("1.21.4", RELEASE),
|
||||
"预发布必须和它的正式版同桶"
|
||||
);
|
||||
// 3. 周更快照:年桶
|
||||
assert_eq!(key("24w14a", SNAPSHOT).as_deref(), Some("24w"));
|
||||
assert_eq!(key("14w02a", SNAPSHOT).as_deref(), Some("14w"));
|
||||
// 4. 远古版:没有主版本
|
||||
assert_eq!(key("rd-132211", ancient(AncientKind::Classic)), None);
|
||||
assert_eq!(key("a1.2.6", ancient(AncientKind::OldAlpha)), None);
|
||||
assert_eq!(key("c0.30_01c", ancient(AncientKind::Classic)), None);
|
||||
assert_eq!(key("b1.7.3", ancient(AncientKind::OldBeta)), None);
|
||||
}
|
||||
|
||||
/// 「按第一个 `.` 切」的坏法要留下痕迹:同一族的版本不许被拆成两个桶。
|
||||
#[test]
|
||||
fn splitting_on_the_first_dot_would_break_these_samples() {
|
||||
// 1.7 / 1.9 / 1.21 是三个不同的桶(按第一个点切会全归到 "1")
|
||||
let buckets = ["1.7.10", "1.9.4", "1.21.4", "1.21.1"]
|
||||
.map(|id| key(id, RELEASE).expect("正式版都有主版本"))
|
||||
.to_vec();
|
||||
assert_eq!(buckets, vec!["1.7", "1.9", "1.21", "1.21"]);
|
||||
assert_ne!(buckets[0], buckets[2]);
|
||||
}
|
||||
|
||||
/// 版本序:`1.21 > 1.9`(字典序会说反),周更年桶同理。
|
||||
#[test]
|
||||
fn the_version_order_is_numeric_not_lexicographic() {
|
||||
use std::cmp::Ordering;
|
||||
assert_eq!(compare("1.21", "1.9"), Ordering::Greater);
|
||||
assert_eq!(compare("1.9", "1.21"), Ordering::Less);
|
||||
assert_eq!(compare("24w", "14w"), Ordering::Greater);
|
||||
assert_eq!(compare("1.21", "1.21"), Ordering::Equal);
|
||||
|
||||
let mut labels = vec!["1.9", "1.21", "24w", "1.7"];
|
||||
labels.sort_by(|left, right| compare(right, left));
|
||||
assert_eq!(labels, vec!["24w", "1.21", "1.9", "1.7"]);
|
||||
}
|
||||
|
||||
/// 桶聚合:数量正确、按版本序降序、无主版本的桶排在最后。
|
||||
#[test]
|
||||
fn buckets_are_counted_and_sorted() {
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
let catalog = Lang::En.catalog();
|
||||
let rows: Vec<super::super::model::VersionRow> = [
|
||||
("1.21.9", RELEASE),
|
||||
("1.21.8", RELEASE),
|
||||
("1.20.4", RELEASE),
|
||||
("24w14a", SNAPSHOT),
|
||||
("rd-132211", ancient(AncientKind::Classic)),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(id, kind)| {
|
||||
super::super::model::VersionRow::of(
|
||||
&unlucky_presenter::dto::VersionSummary {
|
||||
id: id.to_string(),
|
||||
kind,
|
||||
released: "2026-09-20".to_string(),
|
||||
installed: false,
|
||||
},
|
||||
catalog,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let buckets = buckets(&rows);
|
||||
let keys: Vec<Option<&str>> = buckets.iter().map(|bucket| bucket.key.as_deref()).collect();
|
||||
assert_eq!(
|
||||
keys,
|
||||
vec![Some("24w"), Some("1.21"), Some("1.20"), None],
|
||||
"24w > 1.21 > 1.20 > 无主版本"
|
||||
);
|
||||
assert_eq!(buckets[1].count, 2, "1.21 桶里两个版本");
|
||||
assert_eq!(buckets[3].count, 1, "远古版落在 None 桶");
|
||||
}
|
||||
@@ -24,8 +24,7 @@
|
||||
//! 文案按 `Key` 现求——中文环境自然出中文,英文环境不需要任何中文字体。
|
||||
|
||||
use unlucky_presenter::dto::{
|
||||
human_bytes, AncientKind, InstallProgressView, InstallStage, VersionCategory, VersionKind,
|
||||
VersionSummary,
|
||||
human_bytes, InstallProgressView, InstallStage, VersionKind, VersionSummary,
|
||||
};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
@@ -43,82 +42,9 @@ pub fn cancelled_toast(lang: Lang) -> String {
|
||||
lang.catalog().text(Key::DownloadCancelled).to_owned()
|
||||
}
|
||||
|
||||
/// 分类筛选(页面上的四个 filter chip)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Category {
|
||||
/// 不过滤:画快照里有什么就是什么(首帧与 `RefreshAll` 的落点)
|
||||
#[default]
|
||||
All,
|
||||
/// 正式版
|
||||
Release,
|
||||
/// 预览版
|
||||
Snapshot,
|
||||
/// 远古版(快照里按 `AncientKind` 分四类,页面先只认一个 chip)
|
||||
Ancient,
|
||||
}
|
||||
mod category;
|
||||
|
||||
impl Category {
|
||||
/// 四个 chip 的声明顺序(= 画出来的顺序)。
|
||||
pub const ALL: [Category; 4] = [
|
||||
Category::All,
|
||||
Category::Release,
|
||||
Category::Snapshot,
|
||||
Category::Ancient,
|
||||
];
|
||||
|
||||
/// chip 文案的 key。
|
||||
pub const fn label_key(self) -> Key {
|
||||
match self {
|
||||
Category::All => Key::DownloadCategoryAll,
|
||||
Category::Release => Key::DownloadCategoryRelease,
|
||||
Category::Snapshot => Key::DownloadCategorySnapshot,
|
||||
Category::Ancient => Key::DownloadCategoryAncient,
|
||||
}
|
||||
}
|
||||
|
||||
/// 点这个 chip 要派的指令(`None` = 不派:`All` 就是「把当前快照全画出来」)。
|
||||
///
|
||||
/// 远古版在 `VersionCategory` 里是**带子类的**(`Ancient(AncientKind)`),一个 chip 拉不全四类;
|
||||
/// 这里先拉 `Classic`,四子类同屏要靠 presenter 侧支持(已登记 finding)。
|
||||
pub const fn command(self) -> Option<VersionCategory> {
|
||||
match self {
|
||||
Category::All => None,
|
||||
Category::Release => Some(VersionCategory::Release),
|
||||
Category::Snapshot => Some(VersionCategory::Snapshot),
|
||||
Category::Ancient => Some(VersionCategory::Ancient(AncientKind::Classic)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 这个版本属于这个分类吗。
|
||||
pub fn matches(self, kind: VersionKind) -> bool {
|
||||
match self {
|
||||
Category::All => true,
|
||||
Category::Release => kind == VersionKind::Release,
|
||||
Category::Snapshot => kind == VersionKind::Snapshot,
|
||||
Category::Ancient => kind.is_ancient(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从快照里**反推**当前分类:整份列表只有一类 → 那一类;混合或为空 → [`Category::All`]。
|
||||
///
|
||||
/// 快照没有「我上次按哪个分类拉的」这种字段,所以分类靠列表内容反推
|
||||
/// (`ListVersions` 的结果是单一分类,`RefreshAll` 的结果是混合)。
|
||||
pub fn of(versions: &[VersionSummary]) -> Self {
|
||||
let mut kinds = versions.iter().map(|version| version.kind);
|
||||
let Some(first) = kinds.next() else {
|
||||
return Category::All;
|
||||
};
|
||||
if kinds.all(|kind| kind == first) {
|
||||
match first {
|
||||
VersionKind::Release => Category::Release,
|
||||
VersionKind::Snapshot => Category::Snapshot,
|
||||
VersionKind::Ancient(_) => Category::Ancient,
|
||||
}
|
||||
} else {
|
||||
Category::All
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use category::Category;
|
||||
|
||||
/// 版本列表的一行(`ListItem` 的两行档:headline = 版本 id,supporting = `正式版 · 发布于 …`)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -145,16 +71,21 @@ impl VersionRow {
|
||||
supporting: catalog.render(
|
||||
Key::DownloadRowSupporting,
|
||||
&[
|
||||
("kind", catalog.text(kind_key(version.kind))),
|
||||
("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 只有一档,行上再分子类等于两套语言)。
|
||||
const fn kind_key(kind: VersionKind) -> Key {
|
||||
pub(super) const fn kind_label_key(kind: VersionKind) -> Key {
|
||||
match kind {
|
||||
VersionKind::Release => Key::DownloadCategoryRelease,
|
||||
VersionKind::Snapshot => Key::DownloadCategorySnapshot,
|
||||
@@ -285,6 +216,16 @@ pub struct Download {
|
||||
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 {
|
||||
@@ -309,6 +250,9 @@ impl Download {
|
||||
/// 它住在 `App`(照分类筛选的既有做法),因为 `refresh_view` 每次事件都会把整棵模型重建,
|
||||
/// 页面自己存不住任何东西。选中项还要**在当前列表里**才算数:切分类后那一行不在了,
|
||||
/// 就不该还留着一个点不动的「安装」。
|
||||
///
|
||||
/// M107:`query` / `hits` / `buckets` 也走这条路——查询词来自快照的 `download_query`,
|
||||
/// 桶由过滤后的行聚合(第二级侧栏的数据源)。
|
||||
pub fn from_snapshot(
|
||||
state: &AppState,
|
||||
category: Category,
|
||||
@@ -316,12 +260,22 @@ impl Download {
|
||||
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| category.matches(version.kind))
|
||||
.filter(|version| !state.installed_only || version.installed)
|
||||
.filter(|version| query.is_empty() || version.id.to_ascii_lowercase().contains(&query))
|
||||
.filter(|version| matches(version) && category.matches(version.kind))
|
||||
.map(|version| VersionRow::of(version, catalog))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
@@ -354,6 +308,8 @@ impl Download {
|
||||
};
|
||||
|
||||
Self {
|
||||
// 桶由**过滤后的行**聚合:第二级侧栏只列当前分类里真实出现的版本族。
|
||||
buckets: super::group::buckets(&rows),
|
||||
rows,
|
||||
// 分类不再从列表反推(那正是「chip 跳回全部」的来源):谁选的就是谁。
|
||||
// [`Category::of`] 只留给「不经过用户选择」的入口(演示数据的首帧)。
|
||||
@@ -362,6 +318,13 @@ impl Download {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,6 +343,24 @@ impl Download {
|
||||
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)]
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//! 分类筛选(原顶部四个 chip / 现在是第一级侧栏的四个固定项)。
|
||||
//!
|
||||
//! 从 `model.rs` 搬出来:M105/M107 合流后那边到 441 行(超 400 硬线)。
|
||||
//! **纯搬移**:枚举、`matches` 谓词、`command()` 与 `of()` 一字未改,
|
||||
//! 路径仍由 `model.rs` 的 `pub use` 保持 `pages::download::Category`。
|
||||
|
||||
use unlucky_i18n::Key;
|
||||
use unlucky_presenter::dto::{AncientKind, VersionCategory, VersionKind, VersionSummary};
|
||||
|
||||
/// 分类筛选(页面上的四个 filter chip)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Category {
|
||||
/// 不过滤:画快照里有什么就是什么(首帧与 `RefreshAll` 的落点)
|
||||
#[default]
|
||||
All,
|
||||
/// 正式版
|
||||
Release,
|
||||
/// 预览版
|
||||
Snapshot,
|
||||
/// 远古版(快照里按 `AncientKind` 分四类,页面先只认一个 chip)
|
||||
Ancient,
|
||||
}
|
||||
|
||||
impl Category {
|
||||
/// 四个 chip 的声明顺序(= 画出来的顺序)。
|
||||
pub const ALL: [Category; 4] = [
|
||||
Category::All,
|
||||
Category::Release,
|
||||
Category::Snapshot,
|
||||
Category::Ancient,
|
||||
];
|
||||
|
||||
/// chip 文案的 key。
|
||||
pub const fn label_key(self) -> Key {
|
||||
match self {
|
||||
Category::All => Key::DownloadCategoryAll,
|
||||
Category::Release => Key::DownloadCategoryRelease,
|
||||
Category::Snapshot => Key::DownloadCategorySnapshot,
|
||||
Category::Ancient => Key::DownloadCategoryAncient,
|
||||
}
|
||||
}
|
||||
|
||||
/// 点这个 chip 要派的指令(`None` = 不派:`All` 就是「把当前快照全画出来」)。
|
||||
///
|
||||
/// 远古版在 `VersionCategory` 里是**带子类的**(`Ancient(AncientKind)`),一个 chip 拉不全四类;
|
||||
/// 这里先拉 `Classic`,四子类同屏要靠 presenter 侧支持(已登记 finding)。
|
||||
pub const fn command(self) -> Option<VersionCategory> {
|
||||
match self {
|
||||
Category::All => None,
|
||||
Category::Release => Some(VersionCategory::Release),
|
||||
Category::Snapshot => Some(VersionCategory::Snapshot),
|
||||
Category::Ancient => Some(VersionCategory::Ancient(AncientKind::Classic)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 这个版本属于这个分类吗。
|
||||
pub fn matches(self, kind: VersionKind) -> bool {
|
||||
match self {
|
||||
Category::All => true,
|
||||
Category::Release => kind == VersionKind::Release,
|
||||
Category::Snapshot => kind == VersionKind::Snapshot,
|
||||
Category::Ancient => kind.is_ancient(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从快照里**反推**当前分类:整份列表只有一类 → 那一类;混合或为空 → [`Category::All`]。
|
||||
///
|
||||
/// 快照没有「我上次按哪个分类拉的」这种字段,所以分类靠列表内容反推
|
||||
/// (`ListVersions` 的结果是单一分类,`RefreshAll` 的结果是混合)。
|
||||
pub fn of(versions: &[VersionSummary]) -> Self {
|
||||
let mut kinds = versions.iter().map(|version| version.kind);
|
||||
let Some(first) = kinds.next() else {
|
||||
return Category::All;
|
||||
};
|
||||
if kinds.all(|kind| kind == first) {
|
||||
match first {
|
||||
VersionKind::Release => Category::Release,
|
||||
VersionKind::Snapshot => Category::Snapshot,
|
||||
VersionKind::Ancient(_) => Category::Ancient,
|
||||
}
|
||||
} else {
|
||||
Category::All
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
//! 版本管理页的**三栏装配**:类型栏 + 主版本栏 + 列表列(宽档不够时退回顶部 chips)。
|
||||
//!
|
||||
//! ## 三栏各自的内容
|
||||
//!
|
||||
//! | 栏 | 画什么 | 交互 |
|
||||
//! |---|---|---|
|
||||
//! | 版本类型(第一栏) | 折叠开关 + 四个类型项(原顶部 tabs 的四个 chip,语义一字不改) | [`Message::SetCategory`](真指令:`AppCommand::ListVersions`) |
|
||||
//! | 主版本(第二栏) | 「全部版本」+ 快照里出现的每个主版本桶([`super::group`]) | [`Message::SelectMajor`](纯视图态) |
|
||||
//! | 列表 / 内容(第三栏) | 搜索条 + 命中数 + 计数头 + 版本行 + 底部安装面板 | [`Message::SetVersionQuery`] / 选版本 / 安装 |
|
||||
//!
|
||||
//! ## 宽度不在这里拍
|
||||
//!
|
||||
//! 两条栏的宽度来自 [`VersionRails`](宽档裁决,见 `pages/layout/rails.rs` 的数值表)。
|
||||
//! 本文件只做两件事:把裁决出来的数字用上、把「用户手动折叠类型栏」这条覆盖叠上去。
|
||||
//!
|
||||
//! ## 为什么搜索条在第三栏而不是横跨三栏
|
||||
//!
|
||||
//! 搜索过滤的对象是**第三栏的列表**;横跨三栏会让人以为它也在搜侧栏里的类型。放进列表列顶部,
|
||||
//! 「输入 → 下面的行变少」这条因果在视觉上就是同一条竖线;命中数是这条因果的读数,紧贴右侧。
|
||||
//!
|
||||
//! ## 键盘可达
|
||||
//!
|
||||
//! 两层侧栏的每一项都是真组件按钮(`navigation::list_item` / `navigation::tabs` /
|
||||
//! `buttons::icon`):焦点由 iced 的焦点系统给,Tab 依次走「类型栏 → 主版本栏 → 搜索框 → 版本行 →
|
||||
//! 安装」,Enter 激活。页面不自己写焦点环(那是组件的状态层)。
|
||||
|
||||
use iced::widget::tooltip::Position;
|
||||
use iced::widget::{column, container, row, scrollable, tooltip};
|
||||
use iced::{Alignment, Element, Length, Padding};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
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::navigation::list_item::{list_item, ListItem};
|
||||
use crate::app::components::navigation::tabs::{tabs, Tab, Tabs};
|
||||
use crate::app::components::selection::{chip, Chip};
|
||||
use crate::theme::Ui;
|
||||
|
||||
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};
|
||||
|
||||
/// 类型栏里一项的图标。
|
||||
///
|
||||
/// 图标语汇只有 28 个描边字形(没有 `History` / `Camera`),所以借用:
|
||||
/// **全部 = 网格、正式版 = 勾、预览版 = 刷新、远古版 = 档案袋**。已登记为偏离。
|
||||
pub(super) const fn category_icon(category: Category) -> IconKind {
|
||||
match category {
|
||||
Category::All => IconKind::Instances,
|
||||
Category::Release => IconKind::Check,
|
||||
Category::Snapshot => IconKind::Refresh,
|
||||
Category::Ancient => IconKind::Folder,
|
||||
}
|
||||
}
|
||||
|
||||
/// 三栏装配(`Wide` / `Narrow` / `Compact` 三档共用)。
|
||||
pub(super) fn three_column<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
model: &'a Download,
|
||||
rails: VersionRails,
|
||||
) -> Element<'a, Message> {
|
||||
row![
|
||||
panel(
|
||||
ui,
|
||||
rails.type_rail,
|
||||
type_rail(ui, catalog, model, rails.type_rail)
|
||||
),
|
||||
panel(
|
||||
ui,
|
||||
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),
|
||||
]
|
||||
.spacing(super::super::layout::rails::RAIL_GAP)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一条侧栏的外壳:固定宽 + 面板底色 + 独立滚动(栏内内容不横向挤)。
|
||||
fn panel<'a>(
|
||||
ui: &'a Ui,
|
||||
width: f32,
|
||||
content: impl Into<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
container(scrollable(content).height(Length::Fill).style(frame::scroll_style(ui)))
|
||||
.width(Length::Fixed(width))
|
||||
.height(Length::Fill)
|
||||
// 水平不留内边距:140 宽里 `ListItem` 自己的 16+16 已经够,再多 8 会把 `Snapshot` 切掉
|
||||
// (首轮出图实测「Snapsh」)。垂直留 8 让第一项不贴顶。
|
||||
//
|
||||
// 底色取 `Card` 层(`SurfaceContainerLow`,比页面 Canvas 高一档):首轮出图用的是 Canvas,
|
||||
// 两条栏和页面底色一模一样 ⇒ 读起来像「一堆文字浮在左边」,不像两条侧栏。
|
||||
.padding(Padding::from([ui.space(Space::X2), 0.0]))
|
||||
.style(move |_theme| frame::filled(ui.layer(MaterialLayer::Card)))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 第一栏:版本类型(`140` 展开 / `64` 图标条)。
|
||||
fn type_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);
|
||||
let fold = fold_action(ui, model, icon_only);
|
||||
if icon_only {
|
||||
body = body.push(
|
||||
container(fold)
|
||||
.width(Length::Fill)
|
||||
.align_x(Alignment::Center),
|
||||
);
|
||||
} else {
|
||||
body = body.push(rail_title(ui, catalog.text(Key::VersionRailTitle), fold));
|
||||
}
|
||||
|
||||
for category in Category::ALL {
|
||||
let selected = category == model.category;
|
||||
let label = catalog.text(category.label_key());
|
||||
let press = Message::SetCategory(category);
|
||||
|
||||
body = body.push(if icon_only {
|
||||
// 图标条:40×40 图标按钮(命中区 48 由栏内留白补足),选中 = `Tonal`。
|
||||
// 64 宽放不下标签,所以每一项都挂 tooltip(复审建议):四个图标既不说话也没有标题时,
|
||||
// 用户只能靠猜。tooltip 的容器色/圆角走令牌,不新造样式。
|
||||
container(labeled(ui, label, category_icon(category), selected, press))
|
||||
.width(Length::Fill)
|
||||
.align_x(Alignment::Center)
|
||||
.into()
|
||||
} else {
|
||||
let mut item = ListItem::new(label, press).leading(category_icon(category));
|
||||
if selected {
|
||||
item = item.selected();
|
||||
}
|
||||
list_item(ui, item)
|
||||
});
|
||||
}
|
||||
|
||||
body.into()
|
||||
}
|
||||
|
||||
/// 带 tooltip 的图标项(图标条用)。tooltip 位置在右侧——它是竖栏,右边就是内容区,
|
||||
/// 弹在下面会盖住下一项。
|
||||
fn labeled<'a>(
|
||||
ui: &'a Ui,
|
||||
label: &'a str,
|
||||
kind: IconKind,
|
||||
selected: bool,
|
||||
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,
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 选中态 → 图标按钮的三式(`Tonal` = 当前项,与 ListItem 的 `SecondaryContainer` 同色系)。
|
||||
fn select_variant(selected: bool) -> IconVariant {
|
||||
if selected {
|
||||
IconVariant::Tonal
|
||||
} else {
|
||||
IconVariant::Standard
|
||||
}
|
||||
}
|
||||
|
||||
/// 折叠 / 展开类型栏的图标按钮(`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 = 图标按钮的边长):两条栏的标题必须**横向对齐**,
|
||||
/// 而第一栏标题右侧有个 40 高的折叠按钮、第二栏没有。不固定高度的话两行标题会差 12px。
|
||||
fn rail_title<'a>(
|
||||
ui: &'a Ui,
|
||||
title: &'a str,
|
||||
action: Element<'a, Message>,
|
||||
) -> Element<'a, Message> {
|
||||
container(
|
||||
row![
|
||||
container(frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
title,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
))
|
||||
.width(Length::Fill),
|
||||
action,
|
||||
]
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center)
|
||||
.padding(Padding::from([0.0, ui.space(Space::X2)])),
|
||||
)
|
||||
.height(Length::Fixed(ui.metrics().control_height))
|
||||
.align_y(Alignment::Center)
|
||||
.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),
|
||||
));
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// 顶部 chips(可用宽 < 600 时三栏不成立:类型退回 Primary tabs,主版本变成 Secondary chips 行)。
|
||||
///
|
||||
/// 与三栏档**同源**:同一批 [`Category`] / 同一批主版本桶 / 同两条消息,不是另造一套筛选状态。
|
||||
pub(super) fn stacked_chips<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
model: &'a Download,
|
||||
) -> Element<'a, Message> {
|
||||
let mut types = Tabs::primary();
|
||||
for category in Category::ALL {
|
||||
let mut tab = Tab::new(
|
||||
catalog.text(category.label_key()),
|
||||
Message::SetCategory(category),
|
||||
);
|
||||
if category == model.category {
|
||||
tab = tab.selected();
|
||||
}
|
||||
types = types.tab(tab);
|
||||
}
|
||||
|
||||
// 主版本 chips:**从同一份 `major_entries` 画**,并且整行套横向滚动(复审 P2-2)。
|
||||
//
|
||||
// 为什么必须能滚:真实清单里 `Release` 就有 ~20 个桶(1.21…1.7),chips 档的可用宽不到 600、
|
||||
// 一行只放得下 5~6 个。没有横向滚动时后面的桶**永远点不到**——而这正是「主版本下拉」要解决的
|
||||
// 那件事(设计系统里没有 dropdown 组件,所以用可横向滚动的 chips 行承担同一职责)。
|
||||
let mut majors = row![]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center);
|
||||
for entry in major_entries(catalog, model) {
|
||||
majors = majors.push(chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
entry.label,
|
||||
entry.selected,
|
||||
Message::SelectMajor(entry.key.map(str::to_string)),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let strip = scrollable(majors)
|
||||
.direction(scrollable::Direction::Horizontal(
|
||||
scrollable::Scrollbar::new()
|
||||
.width(frame::SCROLLBAR_WIDTH)
|
||||
.scroller_width(frame::SCROLLBAR_WIDTH)
|
||||
.margin(frame::SCROLLBAR_MARGIN),
|
||||
))
|
||||
.height(Length::Fixed(
|
||||
ui.metrics().control_height + ui.space(Space::X2),
|
||||
))
|
||||
.style(frame::scroll_style(ui));
|
||||
|
||||
column![tabs(ui, types), strip]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
@@ -1,23 +1,39 @@
|
||||
//! 版本列表:分类 Tabs + 计数头 + `ListItem` 行(全是 `components/` 的真组件)。
|
||||
//! 列表列:搜索条 + 计数头 + **单行**版本行 + 底部安装面板。
|
||||
//!
|
||||
//! | 位置 | M84(占位) | M94(真组件) |
|
||||
//! ## 这一列的职责边界
|
||||
//!
|
||||
//! | 位置 | 画什么 | 消息 |
|
||||
//! |---|---|---|
|
||||
//! | 分类 | 无(整页只有一行假状态) | [`navigation::tabs`] 的 Primary 档(48 高、3px `Primary` 指示条) |
|
||||
//! | 列表行 | 无 | [`navigation::list_item`] 的两行档(**60** = `row_height` 44 + 每多一行 16) |
|
||||
//! | 计数头 | 无 | `LabelMedium` + `OnSurfaceVariant`(与实例页列表头同一个语言) |
|
||||
//! | 已装标记 | 无 | overline `Installed` + trailing `Check`(`Primary`) |
|
||||
//! | 搜索条 | 真实控件的 `search_bar`(56 高、药丸)+ 清空按钮 + 命中数 | [`Message::SetVersionQuery`] / [`Message::ClearVersionQuery`] |
|
||||
//! | 计数头 | `{count} versions`(`LabelMedium` + `OnSurfaceVariant`,与实例页列表头同一个语言) | 无 |
|
||||
//! | 列表 | `ListItem` + 三列短文本 + 尾随图标按钮 | [`Message::SelectVersion`] / [`Message::InstallVersion`] |
|
||||
//! | 面板 | 见 [`super::install`](M105 的安装选项区**不在这里**) | — |
|
||||
//!
|
||||
//! 行高从「手搓 56」变成 60 的理由与实例页完全一样:真 `ListItem` 自己的几何真源,页面不覆盖。
|
||||
//! ## 版本行为什么必须是**单行**
|
||||
//!
|
||||
//! ## 行距归零(M102)
|
||||
//! 任务硬约束:列表列最窄 424(行宽下限),此时「版本号 + 类型 + 日期 + 安装按钮」四样仍要在
|
||||
//! 一行里。所以:版本号那一格是 `ListItem`(它自己的 `Wrapping::None` 保证不折行,超长由外层
|
||||
//! clip 切掉),类型 / 日期 / 安装按钮三格是**定宽**([`super::super::layout::rails`] 的音阶)。
|
||||
//! 折行的唯一可能来源是定宽格子挤爆容器——那条由列表列下限 424 挡住(`layout/rails/tests.rs`
|
||||
//! 的扫描断言)。
|
||||
//!
|
||||
//! 原先页面在 `ListItem` 的 60/76 高之外又加了 `Space::X1`(4)的行距,行节奏因此与可视高度
|
||||
//! 除不尽——实例页复现成「末行被卡片底边切半行」(视觉复审 ②)。两页一起归零:行距只由组件的
|
||||
//! 几何真源决定(`ListItem` 自己的上下 8px 内边距已经够了),`Space` 里也没有 0 这一档,
|
||||
//! 所以 `list()` 里**不写** `.spacing(…)`。
|
||||
//! ## 搜索的三条口径
|
||||
//!
|
||||
//! | 情形 | 画什么 | 为什么 |
|
||||
//! |---|---|---|
|
||||
//! | 有命中 | 列表照常,侧栏**位置不动**,搜索条右侧显示「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, scrollable};
|
||||
use iced::{Element, Length, Padding};
|
||||
use iced::widget::{column, container, row, scrollable};
|
||||
use iced::{Alignment, Element, Length, Padding};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::icons::IconKind;
|
||||
@@ -26,65 +42,164 @@ 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::app::components::navigation::tabs::{tabs, Tab, Tabs};
|
||||
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::{Category, Download, VersionRow};
|
||||
use super::model::{Download, VersionRow};
|
||||
use super::{install, states};
|
||||
|
||||
/// 分类筛选(Primary Tabs:选中项下方 3px `Primary` 指示条)。
|
||||
///
|
||||
/// 点一个分类 = `Message::SetCategory`,由 app 层翻成 `AppCommand::ListVersions`——
|
||||
/// 页面**不**自己过滤列表(列表内容 = 快照里有什么),所以「点了但没反应」只会发生在
|
||||
/// presenter 没回列表的时候,不会出现「页面过滤过、后端还是旧分类」的两份真相。
|
||||
pub(super) fn categories<'a>(
|
||||
/// 列表列(三栏档与 chips 档共用一份)。
|
||||
pub(super) fn content<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
current: Category,
|
||||
model: &'a Download,
|
||||
) -> Element<'a, Message> {
|
||||
let mut row = Tabs::primary();
|
||||
for category in Category::ALL {
|
||||
let mut tab = Tab::new(
|
||||
catalog.text(category.label_key()),
|
||||
Message::SetCategory(category),
|
||||
);
|
||||
if category == current {
|
||||
tab = tab.selected();
|
||||
}
|
||||
row = row.tab(tab);
|
||||
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,
|
||||
hits_text(catalog, model.hits),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
line = line.push(buttons::icon(
|
||||
ui,
|
||||
IconVariant::Standard,
|
||||
IconKind::Close,
|
||||
Some(Message::ClearVersionQuery),
|
||||
None,
|
||||
));
|
||||
}
|
||||
tabs(ui, row)
|
||||
|
||||
line.into()
|
||||
}
|
||||
|
||||
/// 命中数的文案:`1` 与 `>1` 是两条 key(英文的 `1 matches` 是错的,复审 P2-4)。
|
||||
///
|
||||
/// 中文两侧都是「个匹配」,但**不能**因此只留一条 key:英文的复数规则要在目录里可读,
|
||||
/// 而且将来加第三种语言时这条分支已经在那儿了。
|
||||
pub(super) fn hits_text(catalog: Catalog, count: usize) -> String {
|
||||
let key = if count == 1 {
|
||||
Key::VersionSearchHitsOne
|
||||
} else {
|
||||
Key::VersionSearchHits
|
||||
};
|
||||
catalog.render(key, &[("count", &count.to_string())])
|
||||
}
|
||||
|
||||
/// 「命中在别的类型里」的正文(同样单复数两条 key)。
|
||||
pub(super) fn elsewhere_text(catalog: Catalog, count: usize) -> String {
|
||||
let key = if count == 1 {
|
||||
Key::VersionSearchElsewhereOne
|
||||
} else {
|
||||
Key::VersionSearchElsewhere
|
||||
};
|
||||
catalog.render(key, &[("count", &count.to_string())])
|
||||
}
|
||||
|
||||
/// 计数头(`LabelMedium`):一行 = 一个 key,数字走占位符,不拼片段。
|
||||
pub(super) fn head<'a>(ui: &'a Ui, catalog: Catalog, model: &Download) -> Element<'a, Message> {
|
||||
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", &model.rows.len().to_string())],
|
||||
),
|
||||
catalog.render(Key::DownloadCount, &[("count", &count.to_string())]),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)
|
||||
}
|
||||
|
||||
/// 版本行列表(**独立滚动**:只有列表滚,安装面板钉在底部不跟着滚)。
|
||||
pub(super) fn list<'a>(ui: &'a Ui, catalog: Catalog, model: &'a Download) -> Element<'a, Message> {
|
||||
let rows = column(
|
||||
model
|
||||
/// 当前可见的行:先按分类(模型已过滤),再按第二级侧栏**此刻生效**的主版本桶。
|
||||
///
|
||||
/// 桶的生效值来自 [`super::group::active_bucket`]:搜索期间它恒为「全部」,于是
|
||||
/// 「命中数说 N 个、列表却 0 行」这种自相矛盾在结构上不可能出现(复审 P2-1)。
|
||||
pub(super) fn visible(model: &Download) -> Vec<&VersionRow> {
|
||||
match super::group::active_bucket(model) {
|
||||
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(rows)
|
||||
scrollable(entries)
|
||||
.direction(scrollable::Direction::Vertical(
|
||||
scrollable::Scrollbar::new()
|
||||
.width(frame::SCROLLBAR_WIDTH)
|
||||
@@ -96,11 +211,10 @@ pub(super) fn list<'a>(ui: &'a Ui, catalog: Catalog, model: &'a Download) -> Ele
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一行:headline = 版本 id,supporting = `正式版 · 发布于 2026-09-20`,已装给勾。
|
||||
/// 一行:`ListItem`(版本号,可点选)+ 类型 + 日期 + 安装图标按钮(**四样同一行**)。
|
||||
///
|
||||
/// 点一行 = `Message::SelectVersion(id)`(**页面内选中**,不 dispatch);「安装」是旁边那个
|
||||
/// 独立按钮,点它才派 `AppCommand::InstallVersion`。选中不做乐观回显:行的高亮来自模型,
|
||||
/// 模型在 app 层被改写之后才变。
|
||||
/// 点版本号 = [`Message::SelectVersion`](页面内选中,不 dispatch);点尾随按钮 =
|
||||
/// [`Message::InstallVersion`]。已装的版本给勾 + 禁用按钮(不重复安装)。
|
||||
fn version_row<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
@@ -108,17 +222,70 @@ fn version_row<'a>(
|
||||
entry: &'a VersionRow,
|
||||
) -> Element<'a, Message> {
|
||||
let mut item = ListItem::new(entry.id.as_str(), Message::SelectVersion(entry.id.clone()))
|
||||
.supporting(entry.supporting.as_str())
|
||||
.leading(IconKind::Download);
|
||||
|
||||
if entry.installed {
|
||||
item = item
|
||||
.overline(catalog.text(Key::DownloadInstalled))
|
||||
.trailing(IconKind::Check);
|
||||
item = item.trailing(IconKind::Check);
|
||||
}
|
||||
if model.selected.as_deref() == Some(entry.id.as_str()) {
|
||||
item = item.selected();
|
||||
}
|
||||
|
||||
list_item(ui, item)
|
||||
// 版本号那一格吃掉剩下的宽(`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()
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! 列表列的两条判据:**单行**(四样挤得下)与**搜索框真的在**(不是摆样子)。
|
||||
|
||||
use crate::pages::layout::rails::{
|
||||
version_list_min_width, version_row_min_width, LIST_DATE_WIDTH, LIST_KIND_WIDTH, ROW_GAP,
|
||||
ROW_ICON_BUTTON,
|
||||
};
|
||||
|
||||
/// 行宽下限 424 之下,四样东西仍在一行里(并留出够放 `1.21.9-pre3` 的版本号格)。
|
||||
///
|
||||
/// 抓得住:有人把类型 / 日期 / 按钮的格子改宽(改到 424 放不下时这条红),或者把行宽下限调小。
|
||||
/// 抓不住:真的渲染出来会不会折行(`ListItem` 的 `Wrapping::None` 是组件层的事,成图在报告里)。
|
||||
#[test]
|
||||
fn the_version_row_still_fits_on_one_line_at_the_floor() {
|
||||
let floor = version_list_min_width();
|
||||
assert_eq!(floor, 424.0, "行宽下限就是既有的 424");
|
||||
|
||||
let needed = version_row_min_width();
|
||||
assert!(
|
||||
needed <= floor,
|
||||
"四样固定格加起来要 {needed},超过行宽下限 {floor} 就得折行"
|
||||
);
|
||||
|
||||
// 版本号那一格在最窄情况下还剩多少
|
||||
let label_room = floor - LIST_KIND_WIDTH - LIST_DATE_WIDTH - ROW_ICON_BUTTON - 3.0 * ROW_GAP.dp();
|
||||
assert!(
|
||||
label_room >= 176.0,
|
||||
"版本号那一格只剩 {label_room},`1.21.9-pre3`(11 字符)会被切"
|
||||
);
|
||||
}
|
||||
|
||||
/// 搜索条出现在列表列里(源码层面钉住:「有搜索框」这件事不能悄悄被删掉)。
|
||||
///
|
||||
/// 抓得住:有人把搜索条从列表列里拿掉(那一整块需求就没了)。
|
||||
/// 抓不住:搜索条是否**生效**(效果由 `download/tests.rs` 的跨类型命中数断言与出图负责)。
|
||||
#[test]
|
||||
fn the_search_bar_is_actually_mounted() {
|
||||
let source = include_str!("../rows.rs");
|
||||
assert!(source.contains("search_bar("), "搜索条没装");
|
||||
assert!(source.contains("Message::SetVersionQuery"), "搜索条不产生查询消息");
|
||||
assert!(source.contains("Message::ClearVersionQuery"), "搜索条没有清空入口");
|
||||
}
|
||||
@@ -33,11 +33,17 @@ use unlucky_design::typography::TypeRole;
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
|
||||
use crate::app::components::buttons::{self, Button};
|
||||
use crate::app::components::feedback::{empty_state, EmptyState};
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::frame;
|
||||
use super::super::{Action, Message};
|
||||
use super::model::Category;
|
||||
|
||||
/// 空态图标块边长(12b §15.1:**96×96**,与 `components::feedback::EmptyState` 内部同一个项目值;
|
||||
/// 那里是 `frame::pieces::EMPTY_ICON_BLOCK`(`pub(super)`,本模块取不到,只能同值声明一次)。
|
||||
const EMPTY_ICON_BLOCK: f32 = 96.0;
|
||||
|
||||
/// 两个占位状态共用的外壳:水平 + 垂直都居中(见模块文档的两条理由)。
|
||||
fn centered<'a>(content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
|
||||
@@ -89,3 +95,70 @@ pub(super) fn empty<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> {
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
/// 搜索**一个都不命中**(跨类型也没有):说「没有匹配」+ 一句「换短一点的片段」+ 清空入口。
|
||||
///
|
||||
/// 与 [`empty`] 分开的理由:那个是「这个分类里没有版本」,这个是「这个词没有任何版本」——
|
||||
/// 两种情况该给的下一步动作不同(前者换分类/刷新,后者改搜索词)。
|
||||
pub(super) fn no_match<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> {
|
||||
centered(empty_state(
|
||||
ui,
|
||||
EmptyState::new(
|
||||
IconKind::Search,
|
||||
catalog.text(Key::VersionSearchEmptyTitle),
|
||||
catalog.text(Key::VersionSearchEmptyBody),
|
||||
)
|
||||
.action(
|
||||
catalog.text(Key::VersionSearchClear),
|
||||
Message::ClearVersionQuery,
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
/// **有命中但不在当前分类**:说清「N 个在别的类型里」+ 一个「看全部类型」的出口。
|
||||
///
|
||||
/// 不给出口的话,用户只能自己猜「是不是要切到快照 tab」——这正是本条的验收口径。
|
||||
///
|
||||
/// 标题**不再复用命中数那条 key**(复审 P2-4):原来同一个数在同屏报三遍
|
||||
/// (右上角「1 matches」+ 标题「1 matches」+ 正文「1 versions match」),而且单数时英文是错的。
|
||||
/// 现在标题是一条固定文案,正文的单复数由 [`super::rows::elsewhere_text`] 选 key。
|
||||
pub(super) fn elsewhere<'a>(ui: &'a Ui, catalog: Catalog, hits: usize) -> Element<'a, Message> {
|
||||
let title = catalog.text(Key::VersionSearchElsewhereTitle).to_owned();
|
||||
let body = super::rows::elsewhere_text(catalog, hits);
|
||||
|
||||
// 手搭而不是用 `EmptyState`:它的标题/正文借 `&'a str`,而这里的句子是 `render()` 出来的
|
||||
// `String`(带命中数),借不到 `'a`。字形、字级、按钮全走同一套令牌,视觉上仍是空态。
|
||||
centered(
|
||||
column![
|
||||
frame::glyph_box(
|
||||
IconKind::Search,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
EMPTY_ICON_BLOCK
|
||||
),
|
||||
frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::HeadlineSmall,
|
||||
false,
|
||||
title,
|
||||
ui.color(Role::OnSurface),
|
||||
),
|
||||
frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
body,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
buttons::filled(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::VersionSearchElsewhereAction),
|
||||
Message::SetCategory(Category::All),
|
||||
),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X3))
|
||||
.width(Length::Fill)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! 下载页的判据:**快照 → 模型 → 控件树**三段各自的断言。
|
||||
//! 版本管理页的判据:**快照 → 模型 → 控件树**三段各自的断言。
|
||||
//!
|
||||
//! 每条都写清「抓得住什么 / 抓不住什么」——这里只测纯转换与树能构建,**像素**由报告里的
|
||||
//! 四张图与数值表负责(同 `pages/tests.rs` 的口径)。
|
||||
//! 每条都写清「抓得住什么 / 抓不住什么」——这里只测纯转换与树能构建,**像素**由
|
||||
//! `DevDocs/artifacts/m107/` 的十张图与报告的量测表负责(同 `pages/tests.rs` 的口径)。
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::typography::Platform;
|
||||
@@ -14,8 +14,7 @@ use unlucky_presenter::state::AppState;
|
||||
|
||||
use crate::theme::{Families, Ui};
|
||||
|
||||
use super::super::layout::{content_column_width, frame_metrics, measure_cap_px, PaneKit};
|
||||
use super::super::Message;
|
||||
use super::super::layout::{frame_metrics, PaneKit};
|
||||
use super::model::{Category, Download};
|
||||
use super::options::InstallDraft;
|
||||
|
||||
@@ -46,45 +45,48 @@ fn state_with(versions: Vec<VersionSummary>) -> AppState {
|
||||
state
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 版面(M84 的三条,M94 一条没动)
|
||||
/// 一份覆盖四种版本形态的清单(`1.21.x` / `24w14a` / `rd-132211` / `a1.2.6`)。
|
||||
fn mixed() -> Vec<VersionSummary> {
|
||||
vec![
|
||||
version("1.21.9", VersionKind::Release, false),
|
||||
version("1.21.8", VersionKind::Release, true),
|
||||
version("1.20.4", VersionKind::Release, true),
|
||||
version("24w14a", VersionKind::Snapshot, false),
|
||||
version(
|
||||
"rd-132211",
|
||||
VersionKind::Ancient(AncientKind::Classic),
|
||||
false,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// 单列页:任何窗口宽度都不许出现第二列,正文列不许超过行宽上限。
|
||||
// --------------------------------------------------------------- 版面
|
||||
|
||||
/// 页面仍是单列页(页内三栏是**页内**的事,不改 `PaneKit`):任何宽度都不出现「页面级第二列」。
|
||||
///
|
||||
/// 抓得住:`layout` 把下载页切到两列、或把正文列放过 424。
|
||||
/// 抓不住:页面内部又套了一层更宽的容器(那要看真像素,见报告的量测表)。
|
||||
/// 抓得住:有人把 `Screen::Download` 的 `kit()` 改成 `ListDetail`(那会让页头与页边距跟着变)。
|
||||
/// 抓不住:页内三栏的宽度(那由 `layout/rails/tests.rs` 的数值表钉住)。
|
||||
#[test]
|
||||
fn download_is_single_column_at_every_width() {
|
||||
for width in [600.0, 840.0, 900.0, 1180.0, 2400.0] {
|
||||
fn the_page_kit_is_still_single() {
|
||||
for width in [700.0, 900.0, 1024.0, 1180.0, 2400.0] {
|
||||
let metrics = frame_metrics(PaneKit::Single, width);
|
||||
assert!(metrics.secondary.is_none(), "{width} 宽下载页出现了第二列");
|
||||
assert!(metrics.column <= measure_cap_px() + f32::EPSILON);
|
||||
assert_eq!(metrics.column, content_column_width(width));
|
||||
assert!(metrics.secondary.is_none(), "{width} 出现了页面级第二列");
|
||||
}
|
||||
}
|
||||
|
||||
/// 宽窗下多出来的宽度归**页面块**,不归散文列(M102 订正;旧口径是「留白」)。
|
||||
///
|
||||
/// 抓得住:谁把整页块又压回 424(视觉复审 ① 的原样:块宽 = `column`,1180 下左右各留 ~300px);
|
||||
/// 抓不住:真实像素(报告里的量测表负责)。
|
||||
#[test]
|
||||
fn extra_width_goes_to_the_page_block_not_to_the_prose_column() {
|
||||
let metrics = frame_metrics(PaneKit::Single, 1180.0);
|
||||
assert_eq!(metrics.column, measure_cap_px(), "散文列仍是行宽上限 424");
|
||||
assert_eq!(
|
||||
super::super::frame::body_width(metrics),
|
||||
metrics.available,
|
||||
"页面块必须吃满可用宽度(1044),不是被居中压成 424"
|
||||
);
|
||||
assert!(
|
||||
metrics.available - metrics.column >= 400.0,
|
||||
"两个量要分得开:块宽 1044 比散文列 424 宽出 620"
|
||||
);
|
||||
}
|
||||
// --------------------------------------------------------------- 两层侧栏(见 `tests/rails.rs`)
|
||||
|
||||
mod rails;
|
||||
|
||||
// --------------------------------------------------------------- 搜索一致性 / 复数(见 `tests/search.rs`)
|
||||
|
||||
mod search;
|
||||
|
||||
// --------------------------------------------------------------- 文案(i18n)
|
||||
|
||||
// --------------------------------------------------------------- 快照 → 模型
|
||||
|
||||
/// 真快照进得来:行数、分类、选中项、已装标记全部来自 `AppState`。
|
||||
/// 抓不住:快照字段在 `app/view.rs` 被漏接(那由 `app/update.rs` 的接线测试兜)。
|
||||
/// 真快照进得来:行数、分类、选中项、已装标记、主版本桶全部来自 `AppState`。
|
||||
#[test]
|
||||
fn the_snapshot_becomes_the_page_model() {
|
||||
let mut state = state_with(vec![
|
||||
@@ -114,32 +116,23 @@ fn the_snapshot_becomes_the_page_model() {
|
||||
assert_eq!(model.rows.len(), 2);
|
||||
assert_eq!(model.rows[0].id, "1.21.9");
|
||||
assert!(model.rows[1].installed, "快照说装了就是装了");
|
||||
assert!(!model.rows[0].installed);
|
||||
assert_eq!(
|
||||
model.category,
|
||||
Category::All,
|
||||
"分类是**调用方给的过滤条件**,不再从列表反推(反推正是 chip 跳回「全部」的来源)"
|
||||
);
|
||||
assert_eq!(model.category, Category::All);
|
||||
assert_eq!(
|
||||
model.selected.as_deref(),
|
||||
Some("1.21.9"),
|
||||
"正在安装的版本 = 选中行"
|
||||
);
|
||||
assert!(model.loaded, "有版本 = 已加载");
|
||||
assert_eq!(model.buckets.len(), 1, "两个版本同一个主版本桶");
|
||||
assert_eq!(model.buckets[0].key.as_deref(), Some("1.21"));
|
||||
assert_eq!(model.hits, 2, "没有搜索词时命中数 = 行数");
|
||||
|
||||
let install = model.install.expect("安装进度要转进来");
|
||||
assert!(install.running(), "Downloading 还没收尾");
|
||||
assert!(!install.failed());
|
||||
assert!(install.running());
|
||||
assert_eq!(install.detail, "128/512 files");
|
||||
assert_eq!(
|
||||
install.rate.as_deref(),
|
||||
Some("1.2 MB/s · 1m15s left"),
|
||||
"速率与剩余时间走 key,不搬 core 的中文串"
|
||||
);
|
||||
}
|
||||
|
||||
/// 快照里的两个过滤条件(`installed_only` / `download_query`)原样照用。
|
||||
/// 抓不住:过滤词大小写之外的语言学规则(那属于 presenter 的搜索口径)。
|
||||
#[test]
|
||||
fn presenter_filters_still_apply() {
|
||||
let mut state = state_with(vec![
|
||||
@@ -170,11 +163,11 @@ fn presenter_filters_still_apply() {
|
||||
)
|
||||
};
|
||||
assert_eq!(queried.rows.len(), 2, "版本号片段匹配两个");
|
||||
assert!(queried.rows.iter().all(|row| row.id.starts_with("1.21")));
|
||||
assert_eq!(queried.query, "1.21", "查询词去掉首尾空格后进模型");
|
||||
assert_eq!(queried.hits, 2);
|
||||
}
|
||||
|
||||
/// 空快照 = 加载态;过滤掉所有行 = 空态(抓「把还没拉到画成没有版本」,或反过来)。
|
||||
/// 抓不住:真机上卡在加载态多久(没有超时信号,见模块文档的缺口表)。
|
||||
/// 空快照 = 加载态;过滤掉所有行 = 空态。
|
||||
#[test]
|
||||
fn loading_and_empty_are_different_states() {
|
||||
let loading = Download::from_snapshot(
|
||||
@@ -195,6 +188,73 @@ fn loading_and_empty_are_different_states() {
|
||||
);
|
||||
assert!(!empty.is_loading(), "列表拉到了,不是加载中");
|
||||
assert!(empty.is_empty(), "过滤后没有行 = 空态");
|
||||
assert!(empty.no_match(), "跨类型也没有 ⇒ 没有匹配");
|
||||
assert!(!empty.only_elsewhere());
|
||||
}
|
||||
|
||||
/// **搜索跨类型**:命中数与当前分类无关,行数相关。
|
||||
///
|
||||
/// 抓得住:有人把 `hits` 也算成「当前分类的命中」(那会让「正式版里搜 24w」显示 0 个匹配,
|
||||
/// 用户以为没有这个版本)。
|
||||
/// 抓不住:大小写之外的搜索口径(模糊匹配 / 分词属 presenter)。
|
||||
#[test]
|
||||
fn search_hits_are_cross_category() {
|
||||
let mut state = state_with(mixed());
|
||||
state.download_query = "1.20".to_string();
|
||||
|
||||
let all = Download::from_snapshot(
|
||||
&state,
|
||||
Category::All,
|
||||
&InstallDraft::default(),
|
||||
Lang::En.catalog(),
|
||||
);
|
||||
assert_eq!(all.rows.len(), 1);
|
||||
assert_eq!(all.hits, 1);
|
||||
|
||||
// 同一个词,站在「预览版」分类里:行数 0,命中数仍是 1
|
||||
let snapshot = Download::from_snapshot(
|
||||
&state,
|
||||
Category::Snapshot,
|
||||
&InstallDraft::default(),
|
||||
Lang::En.catalog(),
|
||||
);
|
||||
assert_eq!(snapshot.rows.len(), 0);
|
||||
assert_eq!(snapshot.hits, 1, "命中数是跨类型读数");
|
||||
assert!(snapshot.only_elsewhere(), "有命中但不在当前分类");
|
||||
assert!(!snapshot.no_match(), "不是「没有匹配」");
|
||||
}
|
||||
|
||||
/// 「搜不到」与「在当前分类搜不到」是两种画面(文案与出口都不同)。
|
||||
#[test]
|
||||
fn the_two_search_misses_are_distinguishable() {
|
||||
let mut state = state_with(mixed());
|
||||
|
||||
state.download_query = "zzz".to_string();
|
||||
let nothing = Download::from_snapshot(
|
||||
&state,
|
||||
Category::All,
|
||||
&InstallDraft::default(),
|
||||
Lang::En.catalog(),
|
||||
);
|
||||
assert!(nothing.no_match() && !nothing.only_elsewhere());
|
||||
|
||||
state.download_query = "rd-".to_string();
|
||||
let elsewhere = Download::from_snapshot(
|
||||
&state,
|
||||
Category::Release,
|
||||
&InstallDraft::default(),
|
||||
Lang::En.catalog(),
|
||||
);
|
||||
assert!(elsewhere.only_elsewhere() && !elsewhere.no_match());
|
||||
|
||||
let home = Download::from_snapshot(
|
||||
&state,
|
||||
Category::Ancient,
|
||||
&InstallDraft::default(),
|
||||
Lang::En.catalog(),
|
||||
);
|
||||
assert_eq!(home.rows.len(), 1, "切到远古版就看见了");
|
||||
assert!(!home.no_match() && !home.only_elsewhere());
|
||||
}
|
||||
|
||||
/// 分类反推:混合 = 全部,远古 = 远古(不看子类)。
|
||||
@@ -220,10 +280,7 @@ fn the_category_is_derived_from_the_list() {
|
||||
assert_eq!(Category::of(&[]), Category::All);
|
||||
}
|
||||
|
||||
/// 分类 chip 派的是**真指令**:`VersionCategory` 直接喂给 `AppCommand::ListVersions`。
|
||||
///
|
||||
/// 抓得住:chip 点到不存在的指令、或把 `All` 也派出去(那会白拉一次列表)。
|
||||
/// 抓不住:后端是否真按分类回列表(presenter/host 的测试负责)。
|
||||
/// 分类 chip 派的是**真指令**。
|
||||
#[test]
|
||||
fn category_commands_are_real() {
|
||||
assert_eq!(Category::All.command(), None, "全部 = 画现有快照,不派指令");
|
||||
@@ -234,112 +291,12 @@ fn category_commands_are_real() {
|
||||
);
|
||||
assert_eq!(
|
||||
Category::Ancient.command(),
|
||||
Some(VersionCategory::Ancient(AncientKind::Classic)),
|
||||
"远古版先拉 classic(四子类同屏要 presenter 支持,已登记 finding)"
|
||||
Some(VersionCategory::Ancient(AncientKind::Classic))
|
||||
);
|
||||
assert!(Category::Ancient.matches(VersionKind::Ancient(AncientKind::Unlisted)));
|
||||
assert!(!Category::Release.matches(VersionKind::Snapshot));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 控件树
|
||||
// --------------------------------------------------------------- 控件树(见 `tests/view.rs`)
|
||||
|
||||
/// 五个状态都能构建出控件树(分支走到、不 panic、消息类型对得上)。
|
||||
///
|
||||
/// 抓得住:某个状态分支漏了返回值、或消息类型接错(编译期就红);空态/加载态忘了给内容。
|
||||
/// 抓不住:**画出来长什么样**——像素由报告里的四张图负责,这条只保证「有树」。
|
||||
#[test]
|
||||
fn every_state_builds_a_tree() {
|
||||
let ui = ui();
|
||||
let metrics = frame_metrics(PaneKit::Single, 1180.0);
|
||||
let catalog = Lang::En.catalog();
|
||||
|
||||
let mut loading = Download::loading();
|
||||
loading.selected = None;
|
||||
|
||||
let empty = Download {
|
||||
loaded: true,
|
||||
..Download::loading()
|
||||
};
|
||||
|
||||
let ready = Download {
|
||||
options: None,
|
||||
rows: vec![super::model::VersionRow::of(
|
||||
&version("1.21.9", VersionKind::Release, false),
|
||||
catalog,
|
||||
)],
|
||||
category: Category::Release,
|
||||
selected: Some("1.21.9".to_string()),
|
||||
install: None,
|
||||
loaded: true,
|
||||
};
|
||||
|
||||
let installing = Download {
|
||||
install: Some(super::model::InstallView::of(
|
||||
&InstallProgressView {
|
||||
version: "1.21.9".to_string(),
|
||||
stage: InstallStage::Downloading,
|
||||
files_done: 128,
|
||||
files_total: 512,
|
||||
..InstallProgressView::new()
|
||||
},
|
||||
catalog,
|
||||
)),
|
||||
..ready.clone()
|
||||
};
|
||||
|
||||
let failed = Download {
|
||||
install: Some(super::model::InstallView::of(
|
||||
&InstallProgressView {
|
||||
version: "1.21.9".to_string(),
|
||||
stage: InstallStage::Failed,
|
||||
..InstallProgressView::new()
|
||||
},
|
||||
catalog,
|
||||
)),
|
||||
..ready.clone()
|
||||
};
|
||||
|
||||
for model in [loading, empty, ready, installing, failed] {
|
||||
let _: iced::Element<'_, Message> = super::view(&ui, metrics, &model, Lang::En);
|
||||
}
|
||||
}
|
||||
|
||||
/// 安装失败的模型:可读文案 + 出口都有(不静默)。
|
||||
///
|
||||
/// 抓得住:失败态被画成「已完成」或什么都不给。
|
||||
/// 抓不住:失败原因的具体内容(core 只给中文 `message`,页面按规矩不画它,见模块文档)。
|
||||
#[test]
|
||||
fn a_failed_install_stays_visible() {
|
||||
let model = super::model::InstallView::of(
|
||||
&InstallProgressView {
|
||||
version: "1.21.9".to_string(),
|
||||
stage: InstallStage::Failed,
|
||||
..InstallProgressView::new()
|
||||
},
|
||||
Lang::En.catalog(),
|
||||
);
|
||||
assert!(model.failed());
|
||||
assert!(model.terminal());
|
||||
assert!(!model.running(), "失败之后不再给「取消安装」");
|
||||
assert_eq!(model.stage_label, "Install failed");
|
||||
}
|
||||
|
||||
/// 演示数据自己也得守规矩(它是出图的基线):抓中文字面量混入 / 行数与分类对不上。
|
||||
#[test]
|
||||
fn the_demo_data_is_sane() {
|
||||
let catalog = Lang::En.catalog();
|
||||
let demo = super::demo(catalog);
|
||||
assert!(demo.loaded);
|
||||
assert_eq!(demo.rows.len(), 6);
|
||||
assert_eq!(demo.category, Category::All, "混合列表没有单个分类该亮");
|
||||
assert!(
|
||||
demo.rows
|
||||
.iter()
|
||||
.all(|row| row.supporting.contains("released")),
|
||||
"英文目录下的副文案走英文 key"
|
||||
);
|
||||
assert!(
|
||||
demo.rows.iter().all(|row| !row.supporting.contains('版')),
|
||||
"英文目录下不许出现中文类型词"
|
||||
);
|
||||
}
|
||||
mod view;
|
||||
@@ -17,10 +17,15 @@ use super::*;
|
||||
/// 它只看字面出现,用宏拼 key 名能骗过它。
|
||||
#[test]
|
||||
fn every_download_key_is_referenced_by_the_page() {
|
||||
const SOURCES: [&str; 8] = [
|
||||
const SOURCES: [&str; 11] = [
|
||||
include_str!("../../download.rs"),
|
||||
include_str!("../model.rs"),
|
||||
// M107:分类枚举搬进 `model/category.rs`(`Category::label_key` 在那儿,漏扫会误判死 key)
|
||||
include_str!("../model/category.rs"),
|
||||
include_str!("../rows.rs"),
|
||||
// M107:两级侧栏与分组规则(`VersionRailTitle` / `VersionMajorAll` 的落点)
|
||||
include_str!("../rails.rs"),
|
||||
include_str!("../group.rs"),
|
||||
include_str!("../install.rs"),
|
||||
include_str!("../states.rs"),
|
||||
include_str!("../demo.rs"),
|
||||
@@ -32,7 +37,7 @@ fn every_download_key_is_referenced_by_the_page() {
|
||||
let mut checked = 0;
|
||||
for key in Key::ALL
|
||||
.iter()
|
||||
.filter(|key| key.name().starts_with("Download"))
|
||||
.filter(|key| key.name().starts_with("Download") || key.name().starts_with("Version"))
|
||||
{
|
||||
let needle = format!("Key::{}", key.name());
|
||||
assert!(
|
||||
@@ -45,6 +50,10 @@ fn every_download_key_is_referenced_by_the_page() {
|
||||
checked > 20,
|
||||
"下载页的 key 不该被成批删掉(只查到 {checked} 条)"
|
||||
);
|
||||
assert!(
|
||||
checked > 50,
|
||||
"M107 之后这一页的 key(Download* + Version*)不该少于 50 条,只查到 {checked}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 页面级的文案全部按 `Catalog` 走:中文目录出中文,英文目录出英文,**且不含 core 的中文业务串**。
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! 两层侧栏的护栏(**变异可证**)+ 三栏宽度账。
|
||||
//!
|
||||
//! 从 `download/tests.rs` 拆出来:那边已经贴着 400 行硬线。
|
||||
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dto::{AppEvent, CoreEvent, VersionKind, VersionSummary};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use crate::pages::download::options::InstallDraft;
|
||||
use crate::pages::download::rails::major_entries;
|
||||
use crate::pages::download::{Category, Download};
|
||||
use crate::pages::layout::{
|
||||
available_width, frame_metrics, measure_cap_px, PaneKit, VersionRailTier, VersionRails,
|
||||
RAIL_GAP,
|
||||
};
|
||||
|
||||
/// 三栏要吃掉整条可用宽(不是 424 的居中正文列)——三个宽度档各取一个采样点。
|
||||
///
|
||||
/// 抓得住:有人把第三栏写成 `Length::Fill`(那会让三列宽度之和**超过**可用宽、右侧被挤出屏)。
|
||||
/// 抓不住:像素级对齐(出图在报告里)。
|
||||
#[test]
|
||||
fn the_three_columns_spend_the_whole_available_width() {
|
||||
for (window, tier) in [
|
||||
(1180.0, VersionRailTier::Wide),
|
||||
(900.0, VersionRailTier::Narrow),
|
||||
(700.0, VersionRailTier::Chips),
|
||||
] {
|
||||
let rails = VersionRails::of(available_width(window));
|
||||
assert_eq!(rails.tier, tier, "{window} 的档位");
|
||||
if tier.is_three_column() {
|
||||
let spent = rails.type_rail + rails.major_rail + rails.list + 2.0 * RAIL_GAP;
|
||||
assert!(
|
||||
(spent - available_width(window)).abs() < f32::EPSILON,
|
||||
"{window}:三列 + 两处间距应正好等于可用宽({spent} vs {})",
|
||||
available_width(window)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **两层侧栏都存在、且选中态真的传给列表**——M107 的护栏,改坏要红。
|
||||
///
|
||||
/// 四件事一起钉:
|
||||
/// 1. 页面装配点仍调 `rails::three_column`(三栏装配是唯一入口);
|
||||
/// 2. 装配点里**两个**侧栏函数都被调用(删掉 `major_rail(` → 只剩一级侧栏 ⇒ 红);
|
||||
/// 3. 两级各自都有可点的消息(`SetCategory` / `SelectMajor`);
|
||||
/// 4. 列表的行确实按第二级选中态过滤(`rows::visible` + `selected_major`)。
|
||||
///
|
||||
/// 抓得住:删掉一层侧栏、或者让列表无视 `selected_major`(第二级点了没反应)。
|
||||
/// 抓不住:两层是否**看起来**是两层(那要出图,见报告)。
|
||||
#[test]
|
||||
fn both_rail_levels_exist_and_the_selection_reaches_the_list() {
|
||||
let rails_source = include_str!("../rails.rs");
|
||||
let download_source = include_str!("../../download.rs");
|
||||
let rows_source = include_str!("../rows.rs");
|
||||
|
||||
assert!(
|
||||
download_source.contains("rails::three_column"),
|
||||
"页面装配点不再调三栏装配"
|
||||
);
|
||||
for required in ["type_rail(ui", "major_rail(ui"] {
|
||||
assert!(
|
||||
rails_source.contains(required),
|
||||
"两层侧栏缺了一级:找不到 {required}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
rails_source.contains("Message::SelectMajor"),
|
||||
"第二级(主版本)没有可点的消息"
|
||||
);
|
||||
assert!(
|
||||
rails_source.contains("Message::SetCategory"),
|
||||
"第一级(版本类型)没有可点的消息"
|
||||
);
|
||||
// 列表按第二级的**生效值**过滤(搜索期间放宽到「全部」,见 `group::active_bucket`):
|
||||
// 这两样只要有一样不在,第二级点了就等于没反应。
|
||||
assert!(
|
||||
rows_source.contains("fn visible") && rows_source.contains("group::active_bucket"),
|
||||
"列表没有按第二级选中态过滤"
|
||||
);
|
||||
assert_eq!(Category::ALL.len(), 4, "第一级还是原来那四类,不许新造分类");
|
||||
}
|
||||
|
||||
/// `< 600` 可用宽时两栏都退场、列表吃掉可用宽(chips 档的账)。
|
||||
#[test]
|
||||
fn the_chips_tier_gives_the_whole_width_to_the_list() {
|
||||
let rails = VersionRails::of(564.0);
|
||||
assert_eq!(rails.tier, VersionRailTier::Chips);
|
||||
assert_eq!(rails.type_rail, 0.0);
|
||||
assert_eq!(rails.major_rail, 0.0);
|
||||
assert_eq!(rails.list, 564.0);
|
||||
}
|
||||
|
||||
/// **Chips 档的最后一个桶也要够得着**(复审 P2-2)。
|
||||
///
|
||||
/// 夹具按真实清单的量级造:`1.21 … 1.0` 共 **22 个桶**(`Release` 在 MOJANG 清单里就是这个量级)。
|
||||
/// 三条一起钉:
|
||||
/// 1. `major_entries` 把**每一个桶**都吐出来(含最后一个 `1.0`)——渲染两条路径都吃这一份数据;
|
||||
/// 2. chips 行外面套了 `Direction::Horizontal` 的滚动(源码层,删掉那一层就够不着后面的桶);
|
||||
/// 3. 选中态按**生效值**算:搜索期间「全部版本」点亮(P2-1 的放宽)。
|
||||
///
|
||||
/// 抓得住:把 chips 行的横向滚动拿掉(第 2 条红);或让 `major_entries` 在某个桶处截断(第 1 条红)。
|
||||
/// 抓不住:滚动条在真机上好不好拖(那要出图 + 手感,报告里登记为未验)。
|
||||
#[test]
|
||||
fn every_bucket_is_reachable_in_the_chips_tier() {
|
||||
let versions: Vec<VersionSummary> = (0..=21)
|
||||
.rev()
|
||||
.map(|minor| VersionSummary {
|
||||
id: format!("1.{minor}.4"),
|
||||
kind: VersionKind::Release,
|
||||
released: "2026-09-20".to_string(),
|
||||
installed: false,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut state = AppState::new();
|
||||
state.apply(AppEvent::Core(CoreEvent::VersionListChanged(versions)));
|
||||
|
||||
let catalog = Lang::En.catalog();
|
||||
let mut model =
|
||||
Download::from_snapshot(&state, Category::All, &InstallDraft::default(), catalog);
|
||||
assert_eq!(model.buckets.len(), 22, "夹具应有 22 个主版本桶");
|
||||
|
||||
let entries = major_entries(catalog, &model);
|
||||
assert_eq!(entries.len(), 23, "「全部版本」+ 22 个桶");
|
||||
assert_eq!(
|
||||
entries.last().and_then(|entry| entry.key),
|
||||
Some("1.0"),
|
||||
"最后一个桶(版本序最老的那个)必须在列表里,否则它永远点不到"
|
||||
);
|
||||
assert_eq!(entries[1].label, "1.21", "第一条桶是版本序最新的");
|
||||
|
||||
// 选中态按生效值:选中 1.0 时那一项点亮;搜索期间整体放宽到「全部」
|
||||
model.selected_major = Some("1.0".to_string());
|
||||
let entries = major_entries(catalog, &model);
|
||||
assert!(entries.last().expect("有最后一项").selected);
|
||||
state.download_query = "1.0".to_string();
|
||||
let mut searching =
|
||||
Download::from_snapshot(&state, Category::All, &InstallDraft::default(), catalog);
|
||||
searching.selected_major = Some("1.0".to_string());
|
||||
let entries = major_entries(catalog, &searching);
|
||||
assert!(entries[0].selected, "搜索期间「全部版本」点亮(P2-1)");
|
||||
assert!(
|
||||
entries.iter().skip(1).all(|entry| !entry.selected),
|
||||
"搜索期间不许留下幽灵选中"
|
||||
);
|
||||
|
||||
// chips 行的横向滚动是「够得着」的实现手段
|
||||
let source = include_str!("../rails.rs");
|
||||
assert!(
|
||||
source.contains("Direction::Horizontal"),
|
||||
"chips 档的主版本行必须可横向滚动(否则 22 个桶里大部分点不到)"
|
||||
);
|
||||
assert!(
|
||||
source.contains("major_entries(catalog, model)"),
|
||||
"竖栏与 chips 行必须吃同一份 entries"
|
||||
);
|
||||
}
|
||||
|
||||
/// 宽窗下多出来的宽度归**页面块**,不归散文列(M102 订正;旧口径是「留白」)。
|
||||
///
|
||||
/// 抓得住:谁把整页块又压回 424(视觉复审 ① 的原样:块宽 = `column`,1180 下左右各留 ~300px);
|
||||
/// 抓不住:真实像素(报告里的量测表负责)。
|
||||
#[test]
|
||||
fn extra_width_goes_to_the_page_block_not_to_the_prose_column() {
|
||||
let metrics = frame_metrics(PaneKit::Single, 1180.0);
|
||||
assert_eq!(metrics.column, measure_cap_px(), "散文列仍是行宽上限 424");
|
||||
assert_eq!(
|
||||
crate::pages::frame::body_width(metrics),
|
||||
metrics.available,
|
||||
"页面块必须吃满可用宽度(1044),不是被居中压成 424"
|
||||
);
|
||||
assert!(
|
||||
metrics.available - metrics.column >= 400.0,
|
||||
"两个量要分得开:块宽 1044 比散文列 424 宽出 620"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! 搜索 × 主版本桶的一致性判据(复审 P2-1)+ 复数口径(P2-4)。
|
||||
//!
|
||||
//! 这两条都是「一屏之内两句话打架」那类问题:命中数说 N 个、列表却 0 行;或者英文写 `1 matches`。
|
||||
//! 所以断言都写成**不变式**(对一张输入矩阵扫),而不是挑一个例子。
|
||||
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dto::{AppEvent, CoreEvent, VersionKind, VersionSummary};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use super::super::group;
|
||||
use super::super::model::{Category, Download};
|
||||
use super::super::options::InstallDraft;
|
||||
use super::super::rows;
|
||||
use super::version;
|
||||
|
||||
fn state_with(versions: Vec<VersionSummary>, query: &str) -> AppState {
|
||||
let mut state = AppState::new();
|
||||
state.apply(AppEvent::Core(CoreEvent::VersionListChanged(versions)));
|
||||
state.download_query = query.to_string();
|
||||
state
|
||||
}
|
||||
|
||||
/// 一份带三个主版本桶的清单(`1.21` / `1.20` / `24w`)。
|
||||
fn three_buckets() -> Vec<VersionSummary> {
|
||||
vec![
|
||||
version("1.21.9", VersionKind::Release, false),
|
||||
version("1.21.8", VersionKind::Release, true),
|
||||
version("1.20.4", VersionKind::Release, true),
|
||||
version("1.20.1", VersionKind::Release, false),
|
||||
version("24w14a", VersionKind::Snapshot, false),
|
||||
]
|
||||
}
|
||||
|
||||
/// **P2-1 的核心不变式**:搜索有命中时,列表要么有行,要么落到「命中在别的类型里」那条口径。
|
||||
///
|
||||
/// 扫「四个分类 × 五个桶值 × 四个查询词」:任何一格出现「hits > 0 且可见行为空 **且**
|
||||
/// `only_elsewhere()` 为假」就是自相矛盾的一屏——那正是复审复现的形态:选中 `1.21` 后搜 `1.20`,
|
||||
/// `rows` 非空所以 `only_elsewhere()` 也是假,于是页面落到「没有版本」的空态,而同屏写着「2 matches」。
|
||||
///
|
||||
/// (分类这一层本来就有 `only_elsewhere()`:站在「正式版」搜 `24w` 时列表空是**对的**,
|
||||
/// 页面会说「N 个命中在别的类型里」并给出口——那不是矛盾,所以判据里要把它排除。)
|
||||
///
|
||||
/// 抓得住:把 `group::active_bucket` 的搜索放宽去掉(那一行删掉,这一格立刻红)。
|
||||
/// 抓不住:像素上这两句话是否真的没同屏(那要出图,见 `DevDocs/artifacts/m107/`)。
|
||||
#[test]
|
||||
fn a_search_hit_never_lands_on_an_empty_list() {
|
||||
// A 组:**有搜索词**。桶可以是任意值(包括「桶列表里不存在」的过期值),分类也随便切——
|
||||
// 只要 hits > 0,可见行就不许为空。这一组是 P2-1 的正面判据。
|
||||
for category in Category::ALL {
|
||||
for bucket in [None, Some("1.21"), Some("1.20"), Some("24w"), Some("9.99")] {
|
||||
for query in ["1.20", "1.21", "24w", "zzz"] {
|
||||
let state = state_with(three_buckets(), query);
|
||||
let mut model = Download::from_snapshot(
|
||||
&state,
|
||||
category,
|
||||
&InstallDraft::default(),
|
||||
Lang::En.catalog(),
|
||||
);
|
||||
model.selected_major = bucket.map(str::to_string);
|
||||
|
||||
let visible = rows::visible(&model);
|
||||
let contradiction = model.hits > 0 && visible.is_empty() && !model.only_elsewhere();
|
||||
assert!(
|
||||
!contradiction,
|
||||
"分类 {category:?} / 桶 {bucket:?} / 查询 {query:?}:命中数 {} 但可见行 0 且没有「命中在别的类型」的口径 ⇒ 同屏自相矛盾",
|
||||
model.hits
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// B 组:**没有搜索词**。只有「桶真的在当前分类里」时才有这条承诺;桶是过期值时列表空是
|
||||
// 正确行为(真正要防的是「选中项不在桶列表里」的幽灵态,那由 `rebuild_after_query` 的校验
|
||||
// 与 `a_stale_bucket_choice_cannot_hide_the_hits` 两条一起管)。
|
||||
for category in Category::ALL {
|
||||
let state = state_with(three_buckets(), "");
|
||||
let model = Download::from_snapshot(
|
||||
&state,
|
||||
category,
|
||||
&InstallDraft::default(),
|
||||
Lang::En.catalog(),
|
||||
);
|
||||
for bucket in model
|
||||
.buckets
|
||||
.iter()
|
||||
.filter_map(|bucket| bucket.key.as_deref())
|
||||
{
|
||||
let mut filtered = Download::from_snapshot(
|
||||
&state,
|
||||
category,
|
||||
&InstallDraft::default(),
|
||||
Lang::En.catalog(),
|
||||
);
|
||||
filtered.selected_major = Some(bucket.to_string());
|
||||
assert!(
|
||||
!rows::visible(&filtered).is_empty(),
|
||||
"分类 {category:?} / 真实桶 {bucket:?}:画出来的桶点下去居然是空列表"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个**过期的桶选择**(模型里有个桶列表里不存在的 key)也挡不住搜索命中。
|
||||
///
|
||||
/// 这种 key 正常路径上不会出现(`rebuild_after_query` 会校验、点选只可能点到真实桶),但它是
|
||||
/// 「幽灵选中」的极端形态:搜索结果必须仍然可见,否则用户会看着「N 个命中 + 空列表」。
|
||||
#[test]
|
||||
fn a_stale_bucket_choice_cannot_hide_the_hits() {
|
||||
let state = state_with(three_buckets(), "1.20");
|
||||
let mut model = Download::from_snapshot(
|
||||
&state,
|
||||
Category::All,
|
||||
&InstallDraft::default(),
|
||||
Lang::En.catalog(),
|
||||
);
|
||||
model.selected_major = Some("9.99".to_string());
|
||||
|
||||
assert_eq!(model.hits, 2);
|
||||
assert_eq!(rows::visible(&model).len(), 2, "搜索期间过期桶不生效");
|
||||
assert_ne!(
|
||||
group::active_bucket(&model),
|
||||
Some("9.99"),
|
||||
"过期桶不许成为生效值"
|
||||
);
|
||||
}
|
||||
|
||||
/// 搜索期间第二级桶**放宽到「全部」**(`active_bucket` = None),清空后原来的桶回来。
|
||||
///
|
||||
/// 抓得住:有人「修好」一致性的办法是把 `selected_major` 一起清掉(那会让用户的桶选择丢失,
|
||||
/// 清空搜索后回不到原位)。
|
||||
#[test]
|
||||
fn searching_relaxes_the_bucket_without_losing_the_choice() {
|
||||
let state = state_with(three_buckets(), "1.20");
|
||||
let mut model = Download::from_snapshot(
|
||||
&state,
|
||||
Category::All,
|
||||
&InstallDraft::default(),
|
||||
Lang::En.catalog(),
|
||||
);
|
||||
model.selected_major = Some("1.21".to_string());
|
||||
|
||||
assert!(model.has_query());
|
||||
assert_eq!(
|
||||
model.selected_major.as_deref(),
|
||||
Some("1.21"),
|
||||
"选择本身留着"
|
||||
);
|
||||
assert_eq!(
|
||||
group::active_bucket(&model),
|
||||
None,
|
||||
"搜索期间生效值是「全部」"
|
||||
);
|
||||
assert_eq!(rows::visible(&model).len(), 2, "两个 1.20 的行都看得见");
|
||||
assert!(group::bucket_selected(&model, None), "侧栏点亮「全部版本」");
|
||||
|
||||
// 清空搜索:生效值回到用户原来选的那个桶,而它还在(buckets 由全量行聚合)
|
||||
let state = state_with(three_buckets(), "");
|
||||
let mut model = Download::from_snapshot(
|
||||
&state,
|
||||
Category::All,
|
||||
&InstallDraft::default(),
|
||||
Lang::En.catalog(),
|
||||
);
|
||||
model.selected_major = Some("1.21".to_string());
|
||||
assert_eq!(group::active_bucket(&model), Some("1.21"));
|
||||
assert_eq!(rows::visible(&model).len(), 2, "1.21 桶里两个正式版");
|
||||
}
|
||||
|
||||
/// 没有搜索词时第二级桶照常过滤(这条不许被「放宽」误伤)。
|
||||
#[test]
|
||||
fn without_a_query_the_bucket_still_filters() {
|
||||
let state = state_with(three_buckets(), "");
|
||||
let mut model = Download::from_snapshot(
|
||||
&state,
|
||||
Category::All,
|
||||
&InstallDraft::default(),
|
||||
Lang::En.catalog(),
|
||||
);
|
||||
model.selected_major = Some("1.20".to_string());
|
||||
|
||||
assert_eq!(rows::visible(&model).len(), 2);
|
||||
assert!(!group::bucket_selected(&model, None), "「全部」不点亮");
|
||||
assert!(group::bucket_selected(&model, Some("1.20")));
|
||||
}
|
||||
|
||||
/// 复数口径:`1` 走单数 key,`>1` 走复数 key(复审 P2-4)。
|
||||
///
|
||||
/// 抓得住:把 `hits_text` 的 `count == 1` 分支删掉(英文重新变成「1 matches」)。
|
||||
/// 抓不住:中文的「个」是否自然(中文两边同形,由译审看)。
|
||||
#[test]
|
||||
fn the_search_counts_are_pluralised() {
|
||||
let en = Lang::En.catalog();
|
||||
let zh = Lang::ZhCn.catalog();
|
||||
|
||||
assert_eq!(rows::hits_text(en, 1), "1 match");
|
||||
assert_eq!(rows::hits_text(en, 2), "2 matches");
|
||||
assert_eq!(rows::hits_text(en, 0), "0 matches");
|
||||
assert_eq!(rows::hits_text(zh, 1), "1 个匹配");
|
||||
|
||||
assert_eq!(
|
||||
rows::elsewhere_text(en, 1),
|
||||
"1 version matches, but it is in another type."
|
||||
);
|
||||
assert_eq!(
|
||||
rows::elsewhere_text(en, 3),
|
||||
"3 versions match, but they are in another type."
|
||||
);
|
||||
assert_eq!(
|
||||
rows::elsewhere_text(zh, 1),
|
||||
"有 1 个版本匹配,但它在别的类型里。"
|
||||
);
|
||||
assert_ne!(
|
||||
rows::elsewhere_text(zh, 1),
|
||||
rows::elsewhere_text(zh, 3),
|
||||
"中文也在两套 key 之间切换(不是硬编码同一个串)"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! 控件树那一节:九个状态 × 四档宽度都要能构图(M105/M107 合流后 `tests.rs` 到 433 行,
|
||||
//! 按 AGENTS.md §3-1 拆出来)。
|
||||
//!
|
||||
//! 只测「能构建出树」——**抓不住**画出来长什么样(像素由 `DevDocs/artifacts/m107/` 的图负责)。
|
||||
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dto::{InstallProgressView, InstallStage, VersionKind};
|
||||
|
||||
use crate::pages::download::model::{Download, InstallView, VersionRow};
|
||||
use crate::pages::download::{view, Category, Message};
|
||||
use crate::pages::layout::{frame_metrics, PaneKit};
|
||||
|
||||
use super::{ui, version};
|
||||
|
||||
/// 九个状态都能构建出控件树,且四档宽度都能构建(不 panic、消息类型对得上)。
|
||||
///
|
||||
/// 抓得住:某个状态分支漏了返回值、或消息类型接错(编译期就红);空态/加载态忘了给内容。
|
||||
/// 抓不住:**画出来长什么样**——像素由 `DevDocs/artifacts/m107/` 的图负责。
|
||||
#[test]
|
||||
fn every_state_builds_a_tree_at_every_width() {
|
||||
let ui = ui();
|
||||
let catalog = Lang::En.catalog();
|
||||
|
||||
let loading = Download::loading();
|
||||
let empty = Download {
|
||||
loaded: true,
|
||||
..Download::loading()
|
||||
};
|
||||
let ready = Download {
|
||||
options: None,
|
||||
rows: vec![VersionRow::of(
|
||||
&version("1.21.9", VersionKind::Release, false),
|
||||
catalog,
|
||||
)],
|
||||
category: Category::Release,
|
||||
selected: Some("1.21.9".to_string()),
|
||||
install: None,
|
||||
loaded: true,
|
||||
// M107 的五个新字段:桶 / 查询 / 命中数 / 第二级选中 / 折叠态
|
||||
..Download::default()
|
||||
};
|
||||
|
||||
let installing = Download {
|
||||
install: Some(InstallView::of(
|
||||
&InstallProgressView {
|
||||
version: "1.21.9".to_string(),
|
||||
stage: InstallStage::Downloading,
|
||||
files_done: 128,
|
||||
files_total: 512,
|
||||
..InstallProgressView::new()
|
||||
},
|
||||
catalog,
|
||||
)),
|
||||
..ready.clone()
|
||||
};
|
||||
|
||||
let failed = Download {
|
||||
install: Some(InstallView::of(
|
||||
&InstallProgressView {
|
||||
version: "1.21.9".to_string(),
|
||||
stage: InstallStage::Failed,
|
||||
..InstallProgressView::new()
|
||||
},
|
||||
catalog,
|
||||
)),
|
||||
..ready.clone()
|
||||
};
|
||||
|
||||
// M107:搜索 / 第二级选中 / 搜不到 / 手动折叠四个新增状态的取样
|
||||
let searching = Download {
|
||||
query: "1.21".to_string(),
|
||||
hits: 1,
|
||||
..ready.clone()
|
||||
};
|
||||
let majored = Download {
|
||||
selected_major: Some("1.21".to_string()),
|
||||
..ready.clone()
|
||||
};
|
||||
let no_match = Download {
|
||||
query: "zzz".to_string(),
|
||||
hits: 0,
|
||||
..ready.clone()
|
||||
};
|
||||
let folded = Download {
|
||||
type_rail_folded: true,
|
||||
..ready.clone()
|
||||
};
|
||||
|
||||
// 四档宽度:1180 / 1024(Wide 门槛)/ 900(Narrow)/ 700(Chips)
|
||||
for width in [1180.0, 1024.0, 900.0, 700.0] {
|
||||
let metrics = frame_metrics(PaneKit::Single, width);
|
||||
for model in [
|
||||
loading.clone(),
|
||||
empty.clone(),
|
||||
ready.clone(),
|
||||
installing.clone(),
|
||||
failed.clone(),
|
||||
searching.clone(),
|
||||
majored.clone(),
|
||||
no_match.clone(),
|
||||
folded.clone(),
|
||||
] {
|
||||
let _: iced::Element<'_, Message> = view(&ui, metrics, &model, Lang::En);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 安装失败的模型:可读文案 + 出口都有(不静默)。
|
||||
#[test]
|
||||
fn a_failed_install_stays_visible() {
|
||||
let model = InstallView::of(
|
||||
&InstallProgressView {
|
||||
version: "1.21.9".to_string(),
|
||||
stage: InstallStage::Failed,
|
||||
..InstallProgressView::new()
|
||||
},
|
||||
Lang::En.catalog(),
|
||||
);
|
||||
assert!(model.failed());
|
||||
assert!(model.terminal());
|
||||
assert!(!model.running(), "失败之后不再给「取消安装」");
|
||||
assert_eq!(model.stage_label, "Install failed");
|
||||
}
|
||||
|
||||
/// 演示数据自己也得守规矩(它是出图的基线):八行、四个主版本桶、英文目录下不出现中文类型词。
|
||||
#[test]
|
||||
fn the_demo_data_is_sane() {
|
||||
let catalog = Lang::En.catalog();
|
||||
let demo = crate::pages::download::demo(catalog);
|
||||
assert!(demo.loaded);
|
||||
assert_eq!(demo.rows.len(), 8);
|
||||
assert_eq!(demo.category, Category::All, "混合列表没有单个分类该亮");
|
||||
assert!(demo.query.is_empty(), "缺省演示没有搜索词");
|
||||
assert_eq!(
|
||||
demo.buckets
|
||||
.iter()
|
||||
.map(|bucket| bucket.key.as_deref())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![Some("24w"), Some("1.21"), Some("1.20"), None],
|
||||
"第二级侧栏的桶顺序与内容"
|
||||
);
|
||||
assert!(demo
|
||||
.rows
|
||||
.iter()
|
||||
.all(|row| row.supporting.contains("released")));
|
||||
assert!(demo.rows.iter().all(|row| !row.supporting.contains('版')));
|
||||
}
|
||||
@@ -385,5 +385,15 @@ pub fn frame_metrics(kit: PaneKit, window_width: f32) -> FrameMetrics {
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本管理页的**页内**三栏几何(版本类型栏 / 主版本栏 / 列表列)。
|
||||
///
|
||||
/// 页面级版面只有三种([`PageLayout`]),而版本管理页要在单列里再切三栏——那次裁决住在这里,
|
||||
/// 与 `PaneKit` 分开:`PaneKit` 决定「页面占多宽」,[`rails`] 决定「这宽度怎么切成三栏」。
|
||||
pub mod rails;
|
||||
|
||||
pub use rails::{
|
||||
VersionRailTier, VersionRails, MAJOR_RAIL_WIDTH, RAIL_GAP, RAIL_ICON_WIDTH, TYPE_RAIL_WIDTH,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,227 @@
|
||||
//! 版本管理页的**三栏几何**:版本类型栏 + 主版本栏 + 列表列。纯函数,不碰 iced 控件树。
|
||||
//!
|
||||
//! 这一页是 `PaneKit::Single` 之外的第二种「页内分栏」:外层仍是单列(页头铺满页宽),
|
||||
//! 正文自己切成三栏。所以它不能复用 [`super::PaneKit`](那是**页面级**版面决议),
|
||||
//! 而是在可用宽上再做一次**页内**裁决 —— 本文件就是那次裁决的唯一真源。
|
||||
//!
|
||||
//! ## 1. 两个侧栏的宽度
|
||||
//!
|
||||
//! | 栏 | 展开 | 收成图标条 | 依据 |
|
||||
//! |---|---|---|---|
|
||||
//! | 版本类型栏 | **140** | **64** | 140 = `12b §7` drawer 项「图标 24 + 间距 12 + 8 字符标签」;`Snapshot` 是最长标签(8 字符 ≈ 62px),24+12+62+2×16 内边距 = 130,取 4px 栅格 **140** |
|
||||
//! | 主版本栏 | **140** | **64** | 同一档宽度:主版本是 `1.21` 这种 3~5 字符的短串,140 里放得下「主版本 + 该组版本数」两行;64 里只放得下版本号本身(40 可用宽够 4 字符 `LabelMedium`) |
|
||||
//!
|
||||
//! 64 = `LayoutMetrics::nav_rail_width` 的一半再对齐栅格?**不是**:64 是 4px 栅格上的
|
||||
//! 「图标 24 + 两侧内边距 2×16 + 余量 8」,比导航轨 88 窄一档(导航轨要放 56 宽的选中药丸,
|
||||
//! 页内侧栏只要 40 宽的选中条)。
|
||||
//!
|
||||
//! ## 2. 四档与各自的门槛(门槛都是**算出来的**)
|
||||
//!
|
||||
//! 记 `A` = **页面块宽**([`super::block::body_width`](super::block::body_width) = 可用宽
|
||||
//! 封顶 1440,M102 的口径;≤1440 时它就是可用宽,所以下面这张表在 1180/1024/900/700 上不变)。
|
||||
//!
|
||||
//! | 档 | 门槛 | 类型栏 | 主版本栏 | 列表列 |
|
||||
//! |---|---|---|---|---|
|
||||
//! | `Wide` | `A ≥ 888` | 140 | 140 | `A − 328` |
|
||||
//! | `Narrow` | `676 ≤ A < 888` | **64** | 140 | `A − 252` |
|
||||
//! | `Compact` | `600 ≤ A < 676` | 64 | **64** | `A − 176` |
|
||||
//! | `Chips` | `A < 600` | 0 | 0 | `A` |
|
||||
//!
|
||||
//! 三个门槛的来源(`L` = 列表列下限 [`version_list_min_width`] = 行宽上限 **424**):
|
||||
//!
|
||||
//! - `888` = `available_width(1024)`:任务给的产品口径「≫1024 两栏都展开」,换成可用宽就是它;
|
||||
//! - `676` = `64 + 24 + 140 + 24 + 424`:**类型栏收成图标条之后**三栏仍能让列表停在 424;
|
||||
//! - `600` = `64 + 24 + 64 + 24 + 424`:两条栏都收成 64 也救不回来时,三栏这件事就不成立了。
|
||||
//!
|
||||
//! ## 3. 列表列下限为什么是 424(这条是硬的)
|
||||
//!
|
||||
//! 版本行要**单行不折**地放「版本号 + 类型 + 日期 + 安装按钮」。424 是 [`super::measure_cap_px`]
|
||||
//! ——正文行宽上限(逐行实测 67 字符 ≤72),比这一行真正需要的宽得多:
|
||||
//! `1.21.9-pre3`(11 字符 `BodyMedium` 14px ≈ 67)+ `Snapshot`(`LabelMedium` 12px ≈ 50)
|
||||
//! + `2026-09-20`(10 字符 ≈ 52)+ 安装按钮(`control_height` 40 高的 text 按钮 ≈ 76)
|
||||
//! + 两侧内边距 2×16 + 三处间距 3×12 = **293**。取 424 不是「凑」:它是本仓既有的
|
||||
//! 行宽红线,列表列比它还窄就等于在 424 的正文列旁边又开了一条更窄的正文列。
|
||||
//!
|
||||
//! ## 4. 数值表(报告里贴的是同一张)
|
||||
//!
|
||||
//! 页面拿到的宽度 → 可用宽 `A`(扣 rail 88 与 2×24 页边距)→ 档位 → 三列。
|
||||
//! 「右栏收起 / 展开」列按 M104 的 `content_width`(窗口 − 右栗 − 拖拽缝)计算:
|
||||
//!
|
||||
//! | 窗口 | 右栏 | 页面拿到 | `A` | 档 | 类型 | 主版本 | 列表 |
|
||||
//! |---|---|---|---|---|---|---|---|
|
||||
//! | 1180 | 无(本分支) | 1180 | 1044 | Wide | 140 | 140 | **716** |
|
||||
//! | 1180 | 收起 48 | 1132 | 996 | Wide | 140 | 140 | **668** |
|
||||
//! | 1180 | 展开 320 | 852 | 716 | Narrow | 64 | 140 | **464** |
|
||||
//! | 1024 | 无 | 1024 | 888 | Wide | 140 | 140 | **560** |
|
||||
//! | 900 | 无 | 900 | 764 | Narrow | 64 | 140 | **512** |
|
||||
//! | 880 | 无 | 880 | 744 | Narrow | 64 | 140 | **492** |
|
||||
//! | 700 | 无 | 700 | 564 | Chips | — | — | **564** |
|
||||
//!
|
||||
//! **任务书那句「1180−88−320−140−140 = 492」算错了**:它漏掉 2×24 页边距与 2×24 栏间距
|
||||
//! (共 96)。按真实链路,1180 展开右栏后页面拿到 **852**,可用宽 **716**,
|
||||
//! 两条 140 栏 + 两处间距要 328 ⇒ 列表只剩 **388 < 424**,**那正是「一条栏必须收成 64」的
|
||||
//! 触发条件**;收到 64 之后列表是 **464**(不是 492)。492 这个数在 **880 窗宽**上成立
|
||||
//! (744 − 252 = 492),两者差 28 就是 1180−852−88−48 里被漏掉的那部分。
|
||||
//!
|
||||
//! ## 5. 为什么 Narrow 档收的是**类型栏**而不是主版本栏
|
||||
//!
|
||||
//! 两栏都放 3~5 个字符的短串,但可辨性不同:类型栏是**四个固定项**(有图标可认:列表 / 星 /
|
||||
//! 相机 / 历史),主版本栏放的是**用户正在找的那串版本号**——同一屏里两种 64 栏,先收有图标的那个。
|
||||
//! `Compact` 档才把主版本栏也收成 64(`1.21` 在 40 可用宽里仍是完整串,不会被截断)。
|
||||
|
||||
use unlucky_design::spacing::Space;
|
||||
|
||||
use super::{available_width, measure_cap_px, FrameMetrics};
|
||||
|
||||
/// 版本类型栏(第一栏)展开宽。
|
||||
pub const TYPE_RAIL_WIDTH: f32 = 140.0;
|
||||
/// 主版本栏(第二栏)展开宽。
|
||||
pub const MAJOR_RAIL_WIDTH: f32 = 140.0;
|
||||
/// 两条侧栏收成图标条时的宽度。
|
||||
pub const RAIL_ICON_WIDTH: f32 = 64.0;
|
||||
/// 栏与栏之间的间距(与两列版面的 `PANES_GAP` 同值:同一屏里不出现两种分栏节奏)。
|
||||
pub const RAIL_GAP: f32 = Space::X6.dp();
|
||||
|
||||
/// 列表列下限 = 行宽上限 **424**(见模块文档 §3)。
|
||||
pub fn version_list_min_width() -> f32 {
|
||||
measure_cap_px()
|
||||
}
|
||||
|
||||
/// `Wide` 档门槛:`available_width(1024)` = **888**(产品口径「≫1024 两栏都展开」)。
|
||||
pub fn wide_min_available() -> f32 {
|
||||
available_width(1024.0)
|
||||
}
|
||||
|
||||
/// `Narrow` 档门槛:类型栏 64 + 间距 + 主版本栏 140 + 间距 + 列表下限 = **676**。
|
||||
pub fn narrow_min_available() -> f32 {
|
||||
RAIL_ICON_WIDTH + RAIL_GAP + MAJOR_RAIL_WIDTH + RAIL_GAP + version_list_min_width()
|
||||
}
|
||||
|
||||
/// `Compact` 档门槛:两条 64 栏 + 两处间距 + 列表下限 = **600**。
|
||||
pub fn compact_min_available() -> f32 {
|
||||
RAIL_ICON_WIDTH + RAIL_GAP + RAIL_ICON_WIDTH + RAIL_GAP + version_list_min_width()
|
||||
}
|
||||
|
||||
/// 版本行里「类型」一格的宽度:最长标签 `Snapshot`(8 字符 × `LabelMedium` 12px ≈ 50)+
|
||||
/// 一格间距余量,取 4px 栅格 **84**。
|
||||
pub const LIST_KIND_WIDTH: f32 = 84.0;
|
||||
|
||||
/// 版本行里「日期」一格的宽度:ISO 日期 `2026-09-20`(10 字符 ≈ 52)+ 余量,取 **88**。
|
||||
pub const LIST_DATE_WIDTH: f32 = 88.0;
|
||||
|
||||
/// 版本行尾的安装图标按钮格子宽 = 图标按钮边长 **40**(`IconGeometry::side`;命中区 48 由行高 44 补)。
|
||||
pub const ROW_ICON_BUTTON: f32 = 40.0;
|
||||
|
||||
/// 版本行内三处间距(`Space::X2` = 8):比栏间距小一档,行内节奏不与分栏节奏打架。
|
||||
pub const ROW_GAP: Space = Space::X2;
|
||||
|
||||
/// 版本行的最小内容宽(报告里的推导用它当校验):
|
||||
/// `Listitem 最小格 176 + 84 + 88 + 40 + 3×8 = 412 < 424`。
|
||||
///
|
||||
/// 412 略小于行宽下限 424,说明「424 下四样仍在同一行」不是估计而是有 12px 余量的结论;
|
||||
/// 版本号那一格最窄拿到 `424 − 84 − 88 − 40 − 24 = 188`(比推导里取的 176 还宽 12)。
|
||||
pub fn version_row_min_width() -> f32 {
|
||||
176.0 + LIST_KIND_WIDTH + LIST_DATE_WIDTH + ROW_ICON_BUTTON + 3.0 * ROW_GAP.dp()
|
||||
}
|
||||
|
||||
/// 版本管理页此刻用哪一档(四档,见模块文档 §2)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VersionRailTier {
|
||||
/// 两栏都展开(`A ≥ 888`)。
|
||||
Wide,
|
||||
/// 类型栏收成图标条(`676 ≤ A < 888`)。
|
||||
Narrow,
|
||||
/// 两条栏都收成图标条(`600 ≤ A < 676`)。
|
||||
Compact,
|
||||
/// 三栏不成立:退回顶部 chips(`A < 600`)。
|
||||
Chips,
|
||||
}
|
||||
|
||||
impl VersionRailTier {
|
||||
/// 稳定名字(日志 / 测试 / 出图文件名用)。
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Wide => "wide",
|
||||
Self::Narrow => "narrow",
|
||||
Self::Compact => "compact",
|
||||
Self::Chips => "chips",
|
||||
}
|
||||
}
|
||||
|
||||
/// 这一档还画不画页内侧栏。
|
||||
pub const fn is_three_column(self) -> bool {
|
||||
!matches!(self, Self::Chips)
|
||||
}
|
||||
}
|
||||
|
||||
/// 三栏的宽度裁决结果(页面直接消费,不再自己算)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct VersionRails {
|
||||
/// 档位。
|
||||
pub tier: VersionRailTier,
|
||||
/// 版本类型栏宽(`Chips` 档为 0)。
|
||||
pub type_rail: f32,
|
||||
/// 主版本栏宽(`Chips` 档为 0)。
|
||||
pub major_rail: f32,
|
||||
/// 列表 / 内容列宽。
|
||||
pub list: f32,
|
||||
}
|
||||
|
||||
impl VersionRails {
|
||||
/// 按可用宽裁决(门槛见模块文档 §2)。
|
||||
///
|
||||
/// 降级是**逐档下沉**的,不做「挤一挤」:每一档都保证 `list ≥ `[`version_list_min_width`],
|
||||
/// 除非已经沉到 `Chips`(那时列表就是整条可用宽)。
|
||||
pub fn of(available: f32) -> Self {
|
||||
let (tier, type_rail, major_rail) = if available >= wide_min_available() {
|
||||
(VersionRailTier::Wide, TYPE_RAIL_WIDTH, MAJOR_RAIL_WIDTH)
|
||||
} else if available >= narrow_min_available() {
|
||||
(VersionRailTier::Narrow, RAIL_ICON_WIDTH, MAJOR_RAIL_WIDTH)
|
||||
} else if available >= compact_min_available() {
|
||||
(VersionRailTier::Compact, RAIL_ICON_WIDTH, RAIL_ICON_WIDTH)
|
||||
} else {
|
||||
(VersionRailTier::Chips, 0.0, 0.0)
|
||||
};
|
||||
|
||||
let list = if tier.is_three_column() {
|
||||
(available - type_rail - RAIL_GAP - major_rail - RAIL_GAP).max(0.0)
|
||||
} else {
|
||||
available.max(0.0)
|
||||
};
|
||||
|
||||
Self {
|
||||
tier,
|
||||
type_rail,
|
||||
major_rail,
|
||||
list,
|
||||
}
|
||||
}
|
||||
|
||||
/// 一屏的版本管理页几何(`metrics` 来自 [`super::frame_metrics`])。
|
||||
///
|
||||
/// **读的是块宽**([`super::block::body_width`] = pane 宽封顶 1440,M102 的口径),
|
||||
/// 不是 `metrics.available`:三栏都长在 `frame::body` 那个块里,超宽窗(≥1576)上
|
||||
/// 可用宽 2424 > 块宽 1440,按 available 切会让三列之和超出块、右侧被裁。
|
||||
pub fn of_metrics(metrics: FrameMetrics) -> Self {
|
||||
Self::of(super::block::body_width(metrics))
|
||||
}
|
||||
|
||||
/// 叠上「用户手动折叠了类型栏」这条覆盖。
|
||||
///
|
||||
/// 只动第一栏:收窄到 [`RAIL_ICON_WIDTH`],省下来的宽**原样进列表列**(折叠只会让列表更宽,
|
||||
/// 所以不需要再校验一次 424 下限)。`Chips` 档没有第一栏,返回原值。
|
||||
#[must_use]
|
||||
pub fn with_type_folded(self) -> Self {
|
||||
if !self.tier.is_three_column() || self.type_rail <= RAIL_ICON_WIDTH {
|
||||
return self;
|
||||
}
|
||||
Self {
|
||||
type_rail: RAIL_ICON_WIDTH,
|
||||
list: self.list + (self.type_rail - RAIL_ICON_WIDTH),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,139 @@
|
||||
//! 三栏几何的判据:**报告里的数值表就是这张表**,改任何一个门槛或栏宽都会在这里红。
|
||||
//!
|
||||
//! 每条都写清「抓得住什么 / 抓不住什么」——这里只测纯函数,**像素**由 `DevDocs/artifacts/m107/`
|
||||
//! 的图与报告的量测表负责。
|
||||
|
||||
use super::*;
|
||||
use crate::pages::layout::available_width;
|
||||
|
||||
/// 报告里那张数值表的**逐行快照**(窗口 → 页面拿到多宽 → 可用宽 → 档 → 三列)。
|
||||
///
|
||||
/// 抓得住:任何一处门槛 / 栏宽 / 间距被改动(表里 7 行的档位或列宽立刻对不上)。
|
||||
/// 抓不住:改完之后画面**看起来**是否还行(那要出图,见报告)。
|
||||
#[test]
|
||||
fn the_numeric_table_matches_the_report() {
|
||||
// (页面拿到的宽, 期望档, 类型栏, 主版本栏, 列表)
|
||||
let table: [(f32, VersionRailTier, f32, f32, f32); 7] = [
|
||||
(1180.0, VersionRailTier::Wide, 140.0, 140.0, 716.0),
|
||||
(1132.0, VersionRailTier::Wide, 140.0, 140.0, 668.0),
|
||||
(852.0, VersionRailTier::Narrow, 64.0, 140.0, 464.0),
|
||||
(1024.0, VersionRailTier::Wide, 140.0, 140.0, 560.0),
|
||||
(900.0, VersionRailTier::Narrow, 64.0, 140.0, 512.0),
|
||||
(880.0, VersionRailTier::Narrow, 64.0, 140.0, 492.0),
|
||||
(700.0, VersionRailTier::Chips, 0.0, 0.0, 564.0),
|
||||
];
|
||||
|
||||
for (page_width, tier, type_rail, major_rail, list) in table {
|
||||
let rails = VersionRails::of(available_width(page_width));
|
||||
assert_eq!(rails.tier, tier, "{page_width} 的档位");
|
||||
assert_eq!(rails.type_rail, type_rail, "{page_width} 的类型栏");
|
||||
assert_eq!(rails.major_rail, major_rail, "{page_width} 的主版本栏");
|
||||
assert_eq!(rails.list, list, "{page_width} 的列表列");
|
||||
}
|
||||
}
|
||||
|
||||
/// 三栏档里,列表列**永远**不低于行宽下限 424(任务里那条硬约束:版本行不许折行)。
|
||||
///
|
||||
/// 抓得住:有人把门槛算小了(比如把 `Compact` 档门槛写成 560,列表会掉到 384 以下,
|
||||
/// `1.21.9-pre3 · Snapshot · 2026-09-20 · Install` 这一行就得折)。
|
||||
/// 抓不住:折行到底发生在第几个字符(那要 FreeType 量宽 + 出图)。
|
||||
#[test]
|
||||
fn every_three_column_tier_keeps_the_list_at_the_reading_floor() {
|
||||
let floor = version_list_min_width();
|
||||
let mut sweep = 0;
|
||||
// 4px 栅格扫一遍:从 480 到 2400 的可用宽
|
||||
let mut available = 480.0_f32;
|
||||
while available <= 2400.0 {
|
||||
let rails = VersionRails::of(available);
|
||||
if rails.tier.is_three_column() {
|
||||
assert!(
|
||||
rails.list + f32::EPSILON >= floor,
|
||||
"可用宽 {available}({} 档)的列表列 {} 低于行宽下限 {floor}",
|
||||
rails.tier.name(),
|
||||
rails.list
|
||||
);
|
||||
sweep += 1;
|
||||
}
|
||||
available += 4.0;
|
||||
}
|
||||
assert!(sweep > 100, "扫描没跑到(只命中 {sweep} 档)");
|
||||
}
|
||||
|
||||
/// 档位随可用宽**单调**下沉:变窄只可能降档,不会「窄一点反而三栏都展开」。
|
||||
#[test]
|
||||
fn the_tiers_only_ever_step_down_as_the_width_shrinks() {
|
||||
let order = |tier: VersionRailTier| match tier {
|
||||
VersionRailTier::Wide => 0,
|
||||
VersionRailTier::Narrow => 1,
|
||||
VersionRailTier::Compact => 2,
|
||||
VersionRailTier::Chips => 3,
|
||||
};
|
||||
|
||||
let mut previous = VersionRails::of(4096.0);
|
||||
let mut available = 4092.0_f32;
|
||||
while available >= 200.0 {
|
||||
let rails = VersionRails::of(available);
|
||||
assert!(
|
||||
order(rails.tier) >= order(previous.tier),
|
||||
"{available} 反而比更宽时更「展开」:{:?} → {:?}",
|
||||
previous.tier,
|
||||
rails.tier
|
||||
);
|
||||
previous = rails;
|
||||
available -= 4.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// 三个门槛都是**算出来的**,不是拍的:把它们与文档里的公式对上。
|
||||
#[test]
|
||||
fn the_three_thresholds_are_derived() {
|
||||
assert_eq!(wide_min_available(), 888.0, "1024 窗宽换算的可用宽");
|
||||
assert_eq!(
|
||||
narrow_min_available(),
|
||||
64.0 + 24.0 + 140.0 + 24.0 + 424.0,
|
||||
"类型栏收成图标条后三栏仍成立的最小可用宽"
|
||||
);
|
||||
assert_eq!(
|
||||
compact_min_available(),
|
||||
64.0 + 24.0 + 64.0 + 24.0 + 424.0,
|
||||
"两条栏都收成 64 时的最小可用宽"
|
||||
);
|
||||
assert!(compact_min_available() < narrow_min_available());
|
||||
assert!(narrow_min_available() < wide_min_available());
|
||||
|
||||
// 门槛上的两个采样点:刚好到就进这一档,差 4px 就退一档
|
||||
assert_eq!(
|
||||
VersionRails::of(narrow_min_available()).tier,
|
||||
VersionRailTier::Narrow
|
||||
);
|
||||
assert_eq!(
|
||||
VersionRails::of(narrow_min_available() - 4.0).tier,
|
||||
VersionRailTier::Compact
|
||||
);
|
||||
assert_eq!(
|
||||
VersionRails::of(compact_min_available()).tier,
|
||||
VersionRailTier::Compact
|
||||
);
|
||||
assert_eq!(
|
||||
VersionRails::of(compact_min_available() - 4.0).tier,
|
||||
VersionRailTier::Chips
|
||||
);
|
||||
}
|
||||
|
||||
/// 右栏展开(M104 的 `content_width`:窗口 − 320 − 拖拽缝 8)之后**仍是三栏**,且列表够宽。
|
||||
///
|
||||
/// 这条钉的是任务里那个场景:1180 窗宽开右栏,用户还能看见两条侧栏 + 一个不折行的版本列表。
|
||||
/// 抓得住:有人把 `Narrow` 门槛抬到 716 以上(那会让右栏一展开就掉进 chips)。
|
||||
/// 抓不住:`content_width` 自己算错(那是 M104 的测试)。
|
||||
#[test]
|
||||
fn the_agent_rail_open_at_1180_keeps_three_columns() {
|
||||
// M104 `content_width` = 窗口 − 右栗 320 − 拖拽缝 8
|
||||
let page_width = 1180.0 - 320.0 - 8.0;
|
||||
let rails = VersionRails::of(available_width(page_width));
|
||||
|
||||
assert_eq!(page_width, 852.0);
|
||||
assert_eq!(rails.tier, VersionRailTier::Narrow);
|
||||
assert!(rails.tier.is_three_column(), "右栏展开不许把侧栏收掉");
|
||||
assert_eq!(rails.list, 464.0, "列表列 464(任务书写的 492 漏扣了 96)");
|
||||
assert!(rails.list >= version_list_min_width());
|
||||
}
|
||||
@@ -51,6 +51,21 @@ pub enum Message {
|
||||
CancelInstall,
|
||||
/// 切换下载页分类(M94;app 层翻成 `AppCommand::ListVersions`,`All` 只画现有快照)。
|
||||
SetCategory(Category),
|
||||
/// 搜索版本号(**M107**):查询词写进 `AppState.download_query`,页面走同一条重建路径过滤。
|
||||
///
|
||||
/// 为什么不是「页面自己存一份查询」:下载页是**无状态视图**(每帧由快照重建),页面持有的
|
||||
/// 字段活不过下一次事件;查询词只有落在 presenter 的快照里才能跨帧存活,而
|
||||
/// `Download::from_snapshot` 本来就读 `download_query` —— 复用这条路,过滤只有一份真相。
|
||||
SetVersionQuery(String),
|
||||
/// 清空搜索框(**M107**)。与 [`Self::SetVersionQuery`] 同一支的 `""` 特例,
|
||||
/// 单独一个变体是为了让「清空」这个意图在日志与测试里一眼可辨。
|
||||
ClearVersionQuery,
|
||||
/// 选中第二级侧栏的一个主版本桶(**M107**;`None` = 全部版本)。
|
||||
///
|
||||
/// **纯视图态**(与 [`Self::SelectVersion`] 同一类):不派业务命令,app 层只改视图模型。
|
||||
SelectMajor(Option<String>),
|
||||
/// 折叠 / 展开第一级(版本类型)侧栏(**M107**)。纯视图态。
|
||||
ToggleTypeRail,
|
||||
/// 把现成文本塞进剪贴板(**M95**:诊断卡片的一行摘要,文本由页面造,app 只搬)。
|
||||
CopySummary(String),
|
||||
/// 设置页的一组消息(**M101 `SETTINGSMSG`**):设置页的**唯一**投递口。
|
||||
|
||||
@@ -12,7 +12,7 @@ use super::layout::PaneKit;
|
||||
pub enum Screen {
|
||||
/// 实例(list-detail)。
|
||||
Instances,
|
||||
/// 下载(单列)。
|
||||
/// **版本管理**(原下载页;单列页,但正文自己切三栏:类型 / 主版本 / 列表)。
|
||||
Download,
|
||||
/// 设置(supporting-pane)。
|
||||
Settings,
|
||||
|
||||
@@ -132,7 +132,7 @@ mod tests;
|
||||
|
||||
use agent::agent_entries;
|
||||
use core_text::core_text_entries;
|
||||
use download::download_entries;
|
||||
use download::{download_entries, version_entries};
|
||||
use font::font_entries;
|
||||
use general::general_entries;
|
||||
use instances::instances_entries;
|
||||
@@ -162,6 +162,11 @@ nav_entries! {
|
||||
logs_entries,
|
||||
agent_entries,
|
||||
general_entries,
|
||||
// M107:版本管理页的侧栏 / 搜索文案**追加在链尾**(金表只追加不重排,理由见
|
||||
// `catalog/download.rs` 里 `version_entries` 的文档)。M104 的 `agent_entries` 与 M106 的
|
||||
// `general_entries` 已经在 main 上占了链尾,所以本域排在它们**之后**:
|
||||
// `Key::ALL` 顺序 = 金表顺序 = 先 main 的全部域,再 version。
|
||||
version_entries,
|
||||
finish,
|
||||
{}
|
||||
}
|
||||
|
||||
@@ -95,3 +95,43 @@ macro_rules! download_entries {
|
||||
}
|
||||
|
||||
pub(crate) use download_entries;
|
||||
|
||||
/// **M107 版本管理页**的侧栏 / 搜索文案。
|
||||
///
|
||||
/// 为什么单独一个宏、并**接在装配链最后**:`catalog/golden.rs` 是「`Key` 声明顺序 × 译文」的逐字节
|
||||
/// 快照,M107 的纪律是**只追加不重排**。把新 key 插在 `download_entries` 中间会让 `Key::ALL`
|
||||
/// 的顺序在 `DownloadDismiss` 与 `LogsSubtitleIdle` 之间断开,金表就得整体重排(那正是要避免的
|
||||
/// 风险)。追加在链尾 ⇒ 新 key 落在 `Key::ALL` 末尾 ⇒ 金表末尾追加同样几行即可。
|
||||
macro_rules! version_entries {
|
||||
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
|
||||
$next! { $($rest,)* {
|
||||
$($acc)*
|
||||
// --- version management page (M107) ---
|
||||
VersionRailTitle => ("Version type", "版本类型"),
|
||||
VersionMajorTitle => ("Major version", "主版本"),
|
||||
VersionMajorAll => ("All versions", "全部版本"),
|
||||
VersionSearchPlaceholder => ("Search version numbers", "搜版本号"),
|
||||
VersionSearchClear => ("Clear search", "清空搜索"),
|
||||
VersionSearchHits => ("{count} matches", "{count} 个匹配"),
|
||||
VersionSearchEmptyTitle => ("No matching versions", "没有匹配的版本"),
|
||||
VersionSearchEmptyBody => (
|
||||
"Try a shorter piece of the number — 1.21, 24w, a1.",
|
||||
"试试更短的片段 — 1.21、24w、a1。"
|
||||
),
|
||||
VersionSearchElsewhere => (
|
||||
"{count} versions match, but they are in another type.",
|
||||
"有 {count} 个版本匹配,但它们在别的类型里。"
|
||||
),
|
||||
VersionSearchElsewhereAction => ("Show all types", "看全部类型"),
|
||||
// --- M107 r2:复数口径与「在别处」的标题(追加在域尾 ⇒ `Key::ALL` 顺序只增不动) ---
|
||||
VersionSearchHitsOne => ("1 match", "1 个匹配"),
|
||||
VersionSearchElsewhereTitle => ("Found in another type", "在别的类型里找到了"),
|
||||
VersionSearchElsewhereOne => (
|
||||
"1 version matches, but it is in another type.",
|
||||
"有 1 个版本匹配,但它在别的类型里。"
|
||||
),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use version_entries;
|
||||
@@ -148,4 +148,19 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
|
||||
("SettingsOverridesEmptyBody", "Overrides let one instance keep its own Java runtime, memory or JVM flags while the rest of the launcher keeps the defaults above.", "逐实例覆盖让某个实例保留自己的 Java 运行时、内存或 JVM 参数,其余实例沿用上面的默认值。"),
|
||||
("SettingsOverridesAdd", "Add override", "新增覆盖"),
|
||||
("AgentFallbackBody", "The assistant page is not built yet. Chat lives in the docked panel once the AI mission lands; this screen is the M68 skeleton fallback.", "助手页还没做。AI 接线落地后对话会出现在右侧停靠面板;这一屏是 M68 的骨架回退。"),
|
||||
// --- M107 版本管理页(追加在链尾:`Key::ALL` 顺序 = 金表顺序) ---
|
||||
("VersionRailTitle", "Version type", "版本类型"),
|
||||
("VersionMajorTitle", "Major version", "主版本"),
|
||||
("VersionMajorAll", "All versions", "全部版本"),
|
||||
("VersionSearchPlaceholder", "Search version numbers", "搜版本号"),
|
||||
("VersionSearchClear", "Clear search", "清空搜索"),
|
||||
("VersionSearchHits", "{count} matches", "{count} 个匹配"),
|
||||
("VersionSearchEmptyTitle", "No matching versions", "没有匹配的版本"),
|
||||
("VersionSearchEmptyBody", "Try a shorter piece of the number — 1.21, 24w, a1.", "试试更短的片段 — 1.21、24w、a1。"),
|
||||
("VersionSearchElsewhere", "{count} versions match, but they are in another type.", "有 {count} 个版本匹配,但它们在别的类型里。"),
|
||||
("VersionSearchElsewhereAction", "Show all types", "看全部类型"),
|
||||
// --- M107 r2(追加在末尾:`Key::ALL` 顺序 = 金表顺序) ---
|
||||
("VersionSearchHitsOne", "1 match", "1 个匹配"),
|
||||
("VersionSearchElsewhereTitle", "Found in another type", "在别的类型里找到了"),
|
||||
("VersionSearchElsewhereOne", "1 version matches, but it is in another type.", "有 1 个版本匹配,但它在别的类型里。"),
|
||||
];
|
||||
Reference in new issue
Block a user