feat(app-iced): M109 CONTENTDL —— 内容库页(整合包/Mod/资源包/光影)
用户令「下载页只负责下载整合包和Mod/光影」:新开一页做**内容发现与装入**,
检索走 Modrinth(优先,无需 key)/ CurseForge(`ULL_CF_API_KEY` 环境变量,日志只打
set/unset),装入走 core 的下载引擎真下到实例目录,进度/取消/失败复用下载页那块安装面板。
- **入口(B1)**:`Route::Content` 是新顶层路由,但 `Route::nav()` 复用 `NavSection::Download`
——导航轨仍五项,页头右上是「版本管理 | 内容库」一级切换(版本管理页也加了同一个切换,
否则内容库只有 `ULL_START_ROUTE` 能到)
- **页内四条一级侧栏**(`pages/content/layout.rs` 四档几何:992/856/648 三个门槛都是
「Σ栏宽 + 间距」算出来的):整合包 / Mod / 资源包 / 光影;标签栏 200(`Resource packs`
比 M107 的版本类型名长,140 会切字,已登记偏离)
- **筛选**:关键词(草稿 + 显式「搜索」按钮,不逐字打网络)、来源、游戏版本、加载器
(Fabric/Forge/NeoForge/Quilt)、排序;**分页**(「加载更多」)而不是无限滚动——快照只有一个槽、
翻页要看得见、状态要可截图复现
- **四态**:加载 / 空 / 失败 / 有数据 + 「没有更多了」;筛选条在任何状态下都在(加载中藏筛选条
等于把入口收走)
- **一键装入**:`Content::blockers()` 是纯函数——版本不匹配、加载器不匹配、没有目标实例、
整合包缺管线**四条原因逐条**给出,按钮点之前就禁用;判定与画法分开(model/tests 直接钉)
- **整合包今天不可装**:`ContentKind::Modpack.dir()` 是 `None`,UI 禁用 + 说明,host 侧再挡
一次(绕过 UI 也只会回一条 Failed)。真流程是 M111 的导入管线
- **零 UI 网络**:`presenter/host/addon.rs` + `addon/{sources,http,install}.rs`;无 header 的请求走
core 的 `download_bytes`(信号量限流 + 健康度 + 退避全现成),带 `X-API-KEY` 的只有 CF 一条,
自带 429/5xx 退避(优先吃 `Retry-After`,封顶 30s);密钥不进 DTO/日志/仓库
- 文案 41 条中英进 `crates/i18n`(新域接在装配链末尾 ⇒ `Key::ALL` 与金表只追加)
- 演示数据 `ContentHit → Hit::of` 与产品路径同源(格式化不会两套),`ULL_CONTENT_KIND` /
`ULL_CONTENT_STATE` 供出图矩阵选类型与状态
取证:`DevDocs/artifacts/m109/`(10 张:四类内容 × 浅/深 + 搜索无结果 + 加载器不兼容禁用)
门禁:fmt ✓ / clippy `-D warnings`(三 crate)✓ / app-iced 450+6、presenter 170、i18n 24 全绿;
`cargo tree` 两条零 UI 门禁仍空;全部改动文件 ≤400 行。
This commit is contained in:
1 parent
5adac230fe
commit
6b161227d8
42 files changed
+4071
-25
No files matched your search
@@ -89,7 +89,8 @@ pub(super) fn route_of(nav: NavItem) -> Route {
|
||||
pub(super) fn nav_item_of(route: &Route) -> NavItem {
|
||||
match route {
|
||||
Route::Instances | Route::Detail(_) => NavItem::Instances,
|
||||
Route::Download => NavItem::Download,
|
||||
// M109:内容库与版本管理共用「下载」那一个导航项(B1 裁定)
|
||||
Route::Download | Route::Content => NavItem::Download,
|
||||
Route::Logs => NavItem::Logs,
|
||||
Route::Settings => NavItem::Settings,
|
||||
Route::Agent => NavItem::Ai,
|
||||
|
||||
@@ -146,6 +146,12 @@ pub struct App {
|
||||
/// 已经提示过的取消次数(M105):`AppState::install_cancelled` 是「发生过几次取消」,
|
||||
/// 这里是「其中几次已经弹过提示」。两者不等 ⇒ 弹一次 i18n 提示。
|
||||
pub(super) install_cancelled_seen: u64,
|
||||
/// 内容库页的页面内选择(M109):内容类型 / 选中项目 / 目标实例 / 关键词草稿。
|
||||
///
|
||||
/// 为什么在 `App` 而不是 presenter:这四样是「用户在这一屏上点了什么」,重建快照不该冲掉;
|
||||
/// 而检索条件与结果(会跨页存活、要被事件写)在 `AppState::content`。放进 `view` 模型也
|
||||
/// 不行——`refresh_view` 每次都用快照重建模型,选择会被冲回默认值。
|
||||
pub(super) content: pages::content::PageState,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -246,6 +252,7 @@ impl App {
|
||||
// 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串
|
||||
download_draft: pages::InstallDraft::default(),
|
||||
install_cancelled_seen: 0,
|
||||
content: pages::content::PageState::default(),
|
||||
};
|
||||
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
|
||||
app.rebuild_ui();
|
||||
@@ -338,6 +345,7 @@ impl App {
|
||||
category,
|
||||
&draft,
|
||||
self.lang(),
|
||||
&self.content,
|
||||
);
|
||||
self.view_dirty = false;
|
||||
self.refreshes += 1;
|
||||
|
||||
@@ -43,6 +43,7 @@ use super::state::{App, IDLE_TICK, TICK, TICK_MS, WATCH_WINDOW};
|
||||
use super::CAPTURE_AT_FRAME;
|
||||
|
||||
mod agent;
|
||||
mod content;
|
||||
mod download;
|
||||
mod page_messages;
|
||||
mod settings;
|
||||
@@ -173,6 +174,8 @@ impl App {
|
||||
self.state.navigate(route);
|
||||
// 直达某一页时不要再被「自动选中第一条实例」拉回详情页。
|
||||
self.auto_selected = true;
|
||||
// M109:直达内容库时补一次检索(与页面内切换同一个入口)
|
||||
self.ensure_content_loaded();
|
||||
}
|
||||
|
||||
self.auto_select();
|
||||
@@ -364,7 +367,7 @@ fn heartbeat() -> Task<Message> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 取证开关 `ULL_START_ROUTE` 的取值解析(`settings` / `instances` / `download` / `logs`)。
|
||||
/// 取证开关 `ULL_START_ROUTE` 的取值解析(`settings` / `instances` / `download` / `content` / `logs`)。
|
||||
///
|
||||
/// 为什么是环境变量:`crates/app-iced/src/cli.rs` 不在 M93 的 scope,而外观矩阵必须在**设置页**上
|
||||
/// 出图。语义与 `ULL_APPEARANCE_*` / `ULL_TICK_TRACE` 一致:只影响这一次进程。
|
||||
@@ -376,6 +379,8 @@ fn env_start_route() -> Option<Route> {
|
||||
"settings" => Some(Route::Settings),
|
||||
// M104:AI 独立页也要能直达(三态矩阵的 Detached 那张)
|
||||
"agent" => Some(Route::Agent),
|
||||
// M109:内容库(截图矩阵要能直达;页面内的四条侧栏不在路由里)
|
||||
"content" => Some(Route::Content),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//! 内容库页的消息落点(M109):`pages::Message::Content(..)` 的穷尽分发。
|
||||
//!
|
||||
//! ## 两件事分开(与 `update/download.rs` 同一条纪律)
|
||||
//!
|
||||
//! | 意图 | 谁改状态 | 派指令吗 |
|
||||
//! |---|---|---|
|
||||
//! | 关键词草稿 / 选中项目 / 目标实例 | `App::content`(页面内选择) | 不派 |
|
||||
//! | 检索条件(类型/来源/排序/版本/加载器/关键词提交/翻页/重试) | `AppState::content`(presenter) | 派 `SearchContent` |
|
||||
//! | 装入 | 谁都不改 | 派 `InstallContent` |
|
||||
//!
|
||||
//! 「装入」不改本地状态是关键:装上的证据是 `InstallProgress` 事件回流到安装面板
|
||||
//! (`pages/download::install_panel`),页面自己不做乐观回显。
|
||||
//!
|
||||
//! ## 为什么条件写进 presenter 再派
|
||||
//!
|
||||
//! `AppState::content.query` 是「当前结果对应哪份条件」的唯一真相:结果回来时
|
||||
//! `apply_content` 用它判「迟到的事件要不要丢」。页面自己存一份就会有两份真相。
|
||||
|
||||
use unlucky_presenter::dto::{AppCommand, ContentInstallRequest, ContentQuery, ContentSource};
|
||||
|
||||
use crate::pages::content::{Blocker, Content};
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
/// 进入内容库页时补一次检索(首个用户动作不该是「再点一下搜索」)。
|
||||
///
|
||||
/// 判据是 `AppState::content` 里还没有结果、也没有请求在飞:重复进出不重复打网络。
|
||||
pub(super) fn ensure_content_loaded(&mut self) {
|
||||
if self.state.content.results.is_some() || self.state.content.pending {
|
||||
return;
|
||||
}
|
||||
let query = ContentQuery::first(self.content.kind);
|
||||
self.search_content(query);
|
||||
}
|
||||
|
||||
/// 页面消息(穷尽分发:漏一支报 E0004)。
|
||||
pub(super) fn on_content(&mut self, message: pages::content::Message) -> Task<Message> {
|
||||
match message {
|
||||
pages::content::Message::SetKind(kind) => {
|
||||
// 换类型 = 换一份条件(facet 全变):选中项作废,条件回到该类型的首页
|
||||
self.content.kind = kind;
|
||||
self.content.selected = None;
|
||||
let mut query = ContentQuery::first(kind);
|
||||
// 保留用户已经选好的来源/排序/版本/加载器:换成默认值会让筛选条自己跳回去
|
||||
query.source = self.state.content.query.source;
|
||||
query.sort = self.state.content.query.sort;
|
||||
query.game_version = self.state.content.query.game_version.clone();
|
||||
query.loader = self.state.content.query.loader;
|
||||
self.search_content(query);
|
||||
}
|
||||
pages::content::Message::QueryChanged(text) => {
|
||||
self.content.draft = text;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
pages::content::Message::Search => {
|
||||
let mut query = self.state.content.query.clone();
|
||||
query.text = self.content.draft.trim().to_string();
|
||||
query.offset = 0;
|
||||
self.search_content(query);
|
||||
}
|
||||
pages::content::Message::SetSource(source) => {
|
||||
if source == ContentSource::Curseforge && !self.curseforge_available() {
|
||||
// 筛选条那条本来就是禁用的;这里再挡一次(防绕过 UI 的消息)
|
||||
return Task::none();
|
||||
}
|
||||
self.with_query(|query| query.source = source);
|
||||
}
|
||||
pages::content::Message::SetSort(sort) => self.with_query(|query| query.sort = sort),
|
||||
pages::content::Message::SetVersion(version) => {
|
||||
self.with_query(|query| query.game_version = version);
|
||||
}
|
||||
pages::content::Message::SetLoader(loader) => {
|
||||
self.with_query(|query| query.loader = loader)
|
||||
}
|
||||
pages::content::Message::Select(id) => {
|
||||
self.content.selected = Some(id);
|
||||
self.view_dirty = true;
|
||||
}
|
||||
pages::content::Message::SetTarget(id) => {
|
||||
self.content.target = Some(id);
|
||||
self.view_dirty = true;
|
||||
}
|
||||
pages::content::Message::LoadMore => {
|
||||
let next = self.state.content.query.next_page();
|
||||
self.search_content(next);
|
||||
}
|
||||
pages::content::Message::Retry => {
|
||||
let mut query = self.state.content.query.clone();
|
||||
query.offset = 0;
|
||||
self.search_content(query);
|
||||
}
|
||||
pages::content::Message::Install => {
|
||||
if let Some(request) = self.content_install_request() {
|
||||
self.dispatch(AppCommand::InstallContent(Box::new(request)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 改一个筛选字段并**立刻重查**(第一页)。
|
||||
fn with_query(&mut self, change: impl FnOnce(&mut ContentQuery)) {
|
||||
let mut query = self.state.content.query.clone();
|
||||
change(&mut query);
|
||||
query.offset = 0;
|
||||
self.search_content(query);
|
||||
}
|
||||
|
||||
/// 提交一次检索:条件先落进 presenter(迟到事件的判据),再派指令。
|
||||
fn search_content(&mut self, query: ContentQuery) {
|
||||
self.state.set_content_query(query.clone());
|
||||
self.view_dirty = true;
|
||||
self.dispatch(AppCommand::SearchContent(query));
|
||||
}
|
||||
|
||||
/// CF 源可用吗(结果里带回的那一位;还没拉过 ⇒ `false`)。
|
||||
fn curseforge_available(&self) -> bool {
|
||||
self.state
|
||||
.content
|
||||
.results
|
||||
.as_ref()
|
||||
.is_some_and(|results| results.curseforge)
|
||||
}
|
||||
|
||||
/// 组一份内容库的「装入」请求;闸门与页面上的按钮**同一个判据**(`Content::can_install`)。
|
||||
///
|
||||
/// 名字带 `content_`:M105 的版本安装已经占了 `install_request`(版本 → `CreateRequest`),
|
||||
/// 两者语义完全不同,撞名会被编译器拦下(本轮 rebase 撞过)。
|
||||
///
|
||||
/// 这里再判一次不是重复劳动:页面禁用的只是那一个按钮,消息仍然可能从别的路径进来,
|
||||
/// 而 host 侧对整合包会直接回失败——三道闸门同一个结论,才不会出现「有的路能过」。
|
||||
fn content_install_request(&self) -> Option<ContentInstallRequest> {
|
||||
let catalog = unlucky_i18n::Lang::En.catalog();
|
||||
let model = Content::from_snapshot(&self.state, &self.content, catalog);
|
||||
if !model.can_install() {
|
||||
// 有原因就打印出来(取证用);没有选中项时什么都不做
|
||||
for reason in model.blocker_reasons(catalog) {
|
||||
eprintln!("[content] 装入被挡下:{reason}");
|
||||
}
|
||||
if model.blockers.contains(&Blocker::NeedsPipeline) {
|
||||
eprintln!("[content] 整合包装入未实现(M111)");
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let hit = model.selected_hit()?;
|
||||
let target = model
|
||||
.targets
|
||||
.iter()
|
||||
.find(|target| Some(&target.id) == model.target.as_ref())?;
|
||||
Some(ContentInstallRequest {
|
||||
project: hit.id.clone(),
|
||||
title: hit.title.clone(),
|
||||
// 整合包走不到这里:`ContentKind::Modpack` 的 `dir()` 是 `None` ⇒ 必然带
|
||||
// `Blocker::NeedsPipeline` ⇒ `can_install()` 为假。所以这里就是「单文件」那一类。
|
||||
kind: model.kind,
|
||||
source: hit.source,
|
||||
instance: target.id.clone(),
|
||||
game_version: target.mc_version.clone(),
|
||||
loader: target.loader,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -43,9 +43,15 @@ impl App {
|
||||
|
||||
match message {
|
||||
pages::Message::Navigate(route) => {
|
||||
// M109:进内容库时补一次检索(首个动作不该是「再点一下搜索」)。
|
||||
// 判据在 `ensure_content_loaded` 里(已有结果/请求在飞就不重复打网络)。
|
||||
let entering_content = route == Route::Content;
|
||||
self.state.navigate(route);
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
if entering_content {
|
||||
self.ensure_content_loaded();
|
||||
}
|
||||
}
|
||||
pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)),
|
||||
pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)),
|
||||
@@ -57,6 +63,8 @@ impl App {
|
||||
// 这是**唯一**一处绕开 M101 投递口的页面消息:语言档位不是「某一组的状态」,
|
||||
// 它要落盘到 `ui.language` 并重建整棵视图模型(设置页的语言行只是它的第一个出口)。
|
||||
pages::Message::Language(choice) => self.set_language(choice),
|
||||
// M109:内容库那一套意图在自己的文件里穷尽分发(`update/content.rs`)
|
||||
pages::Message::Content(message) => return self.on_content(message),
|
||||
// M95:摘要落剪贴板(文本由页面造,这里只搬)
|
||||
pages::Message::CopySummary(text) => return iced::clipboard::write(text),
|
||||
// 下面四支已由 `on_download` 提前消化;显式列出而不是 `_ => {}`,见模块文档
|
||||
|
||||
@@ -111,6 +111,7 @@ pub(super) fn model(
|
||||
category: pages::Category,
|
||||
draft: &pages::InstallDraft,
|
||||
lang: Lang,
|
||||
content: &pages::content::PageState,
|
||||
) -> pages::Model {
|
||||
let route = state.route().clone();
|
||||
let screen = pages::Screen::of(&route).unwrap_or(pages::Screen::Instances);
|
||||
@@ -141,6 +142,8 @@ pub(super) fn model(
|
||||
active: route.nav(),
|
||||
instances: pages::Instances { rows, detail },
|
||||
download: pages::Download::from_snapshot(state, category, draft, lang.catalog()),
|
||||
// M109:检索条件/结果来自快照,页面内选择(类型/选中/目标/草稿)来自 `App::content`
|
||||
content: pages::content::Content::from_snapshot(state, content, lang.catalog()),
|
||||
logs: logs_model(state).with_status(lang),
|
||||
lang,
|
||||
forced: forced.map(|state| match state {
|
||||
@@ -280,7 +283,16 @@ mod tests {
|
||||
/// 测试用的薄包装:默认宽度 / 默认分类 / 空草稿。
|
||||
fn at(state: &AppState, forced: Option<crate::cli::ForcedState>) -> pages::Model {
|
||||
let (draft, lang) = (pages::InstallDraft::default(), Lang::En);
|
||||
model(state, 1180.0, forced, pages::Category::All, &draft, lang)
|
||||
let page = pages::content::PageState::default();
|
||||
model(
|
||||
state,
|
||||
1180.0,
|
||||
forced,
|
||||
pages::Category::All,
|
||||
&draft,
|
||||
lang,
|
||||
&page,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//! # 内容库页(M109 `CONTENTDL`):整合包 / Mod / 资源包 / 光影
|
||||
//!
|
||||
//! 用户令:「下载页只负责下载整合包和Mod/光影」。于是版本管理页(M107/M108 改名)只做版本,
|
||||
//! **这一页**做内容:四条内容类型(页内一级侧栏)+ 关键词/来源/版本/加载器/排序筛选 +
|
||||
//! 结果列表与详情面板 + **一键装入指定实例**。
|
||||
//!
|
||||
//! ## 入口(B1 裁定:不新增导航项)
|
||||
//!
|
||||
//! `Route::Content` 是新顶层路由,但 `Route::nav()` 返回 `NavSection::Download`:导航轨仍是
|
||||
//! 五项,页头右上是「版本管理 | 内容库」一级切换([`parts::section_switcher`])。
|
||||
//! 为什么不是第六个图标:轨宽 88 塞六项会挤,且用户要的是「同一入口下两块内容」。
|
||||
//!
|
||||
//! ## 分页 vs 无限滚动:**选分页**
|
||||
//!
|
||||
//! 三条理由,都是这一页的实际约束:
|
||||
//!
|
||||
//! 1. **快照只有一个槽**(`AppState::content.results`):无限滚动要求「滚到底就自动续」,而
|
||||
//! 自动续在没有虚拟化列表的 iced 里会一次挂上几百个控件;
|
||||
//! 2. **翻页要有明确出口**:站点限流 / 失败重试都发生在「下一批」上,用户得能看见自己正在
|
||||
//! 等第 3 页,而不是画面莫名一卡;
|
||||
//! 3. **可截图复现**:分页的每一次状态都是一份确定的快照,D30 的矩阵能钉住「还有更多 / 没有更多了」。
|
||||
//!
|
||||
//! 所以尾部是一个「加载更多」按钮 + 一句「没有更多了」([`parts::more`]),追加逻辑在
|
||||
//! presenter 的 `AppState::apply_content`(同条件换页 ⇒ 追加;换条件 ⇒ 整批替换;迟到 ⇒ 丢掉)。
|
||||
//!
|
||||
//! ## 四态(都必须能画出来)
|
||||
//!
|
||||
//! | 态 | 判据 | 画什么 |
|
||||
//! |---|---|---|
|
||||
//! | 加载 | `pending` 或还没有结果 | 一句「正在加载」+ 说明 + 重试 |
|
||||
//! | 空 | 拉到了、零命中 | 真 `EmptyState` + 重试 CTA |
|
||||
//! | 失败 | `failed` 有原因 | `Error` 图标 + 原因原文 + 重试(实心) |
|
||||
//! | 有数据 | 有命中项 | 计数头 + 列表 +(还有更多 ⇒ 加载更多 / 否则 ⇒ 没有更多了) |
|
||||
//!
|
||||
//! ## 不在这里做的事
|
||||
//!
|
||||
//! - **不发 HTTP**:检索与装入都在 presenter 的 `host/addon`(任务书第 4 条)。
|
||||
//! - **不做乐观回显**:点「装入」不改本地状态,进度只由 `InstallProgress` 事件回流。
|
||||
//! - **不自己画安装面板**:复用 `pages::download::install_panel`(同一个函数、同一套文案)。
|
||||
//!
|
||||
//! ## 偏离登记
|
||||
//!
|
||||
//! | # | 偏离 | 为什么 | 何时收口 |
|
||||
//! |---|---|---|---|
|
||||
//! | 1 | 四种内容类型借用现有字形(`Folder`/`Instances`/`Download`/`Play`) | 设计令牌的图标集还没有这四枚 | 图标集补「整合包 / Mod / 资源包 / 光影」时 |
|
||||
//! | 2 | 本页自带一份栏几何(`content/layout.rs`) | M107 的 `pages/layout/rails.rs` 未并入本分支的 base | M107 合并后改为复用、删掉本文件 |
|
||||
//! | 3 | 整合包的「装入」是禁用态 | core 没有远程整合包的安装管线(M111) | M111 落地后接同一入口 |
|
||||
//! | 4 | 安装进度只有阶段级(无字节条) | presenter 依赖表里没有 tokio/futures,拿不到 `ProgressEmitter` 的接收端 | presenter 补依赖后换成字节级 |
|
||||
|
||||
mod demo;
|
||||
mod layout;
|
||||
mod message;
|
||||
mod model;
|
||||
mod parts;
|
||||
mod view;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use message::Message;
|
||||
pub use model::{loader_name, Blocker, Content, ContentQueryView, Hit, PageState, Status, Target};
|
||||
|
||||
use iced::Element;
|
||||
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
pub use unlucky_presenter::dto::ContentKind;
|
||||
|
||||
use crate::pages::layout::FrameMetrics;
|
||||
use crate::pages::Message as PageMessage;
|
||||
use crate::theme::Ui;
|
||||
|
||||
/// 整页入口(`pages/mod.rs` 的 `render` 调它;页面层不认识 presenter 的 DTO)。
|
||||
pub fn view<'a>(
|
||||
ui: &'a Ui,
|
||||
metrics: FrameMetrics,
|
||||
model: &'a Content,
|
||||
lang: Lang,
|
||||
) -> Element<'a, PageMessage> {
|
||||
view::view(ui, metrics, model, lang)
|
||||
}
|
||||
|
||||
/// 演示数据(`pages_demo` 出图与单测用;产品路径走 `Content::from_snapshot`)。
|
||||
pub fn demo(kind: ContentKind, lang: Lang) -> Content {
|
||||
demo::demo(kind, lang)
|
||||
}
|
||||
|
||||
/// 页头那条一级切换(版本管理页也要用同一个函数:两页共用一个入口才不会各画一套)。
|
||||
pub(crate) fn section_switcher<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: unlucky_i18n::Catalog,
|
||||
) -> Element<'a, PageMessage> {
|
||||
parts::section_switcher(ui, catalog, true)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! 内容库的演示数据(`pages_demo` 出图与单测用;产品路径永远走 `Content::from_snapshot`)。
|
||||
//!
|
||||
//! ## 为什么要环境变量选「哪一屏」
|
||||
//!
|
||||
//! D30 的验收矩阵要求「四类内容 × 浅/深」加「搜索无结果」「加载器不兼容禁用」两种状态,
|
||||
//! 共 10 张。`pages_demo` 的 `--screen=` 只够选到**哪一页**,选不到「同一页的哪一态」;
|
||||
//! 而 `pages/harness.rs` 与 `cli.rs` 不在本 mission 的 scope。
|
||||
//!
|
||||
//! 所以沿用本仓既有的取证开关范式(`ULL_START_ROUTE` / `ULL_APPEARANCE_*` / `ULL_TICK_TRACE`):
|
||||
//!
|
||||
//! | 变量 | 取值 | 作用 |
|
||||
//! |---|---|---|
|
||||
//! | `ULL_CONTENT_KIND` | `modpack` / `mod` / `resourcepack` / `shader` | 选内容类型(默认 `mod`) |
|
||||
//! | `ULL_CONTENT_STATE` | `ok` / `empty` / `incompatible` | 选状态(默认 `ok`) |
|
||||
//!
|
||||
//! 只影响本来就用演示数据的 harness 路径;产品二进制里 `demo()` 根本不会被调用。
|
||||
|
||||
use unlucky_i18n::{Catalog, Lang};
|
||||
use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSort, ContentSource, LoaderKind};
|
||||
|
||||
use super::model::{blockers_of, Content, ContentQueryView, Hit, Status, Target};
|
||||
use crate::pages::download::Download;
|
||||
|
||||
/// 一个固定的演示环境:两个实例(一个 Fabric / 一个 Forge)。
|
||||
fn targets() -> Vec<Target> {
|
||||
vec![
|
||||
Target {
|
||||
id: "survival".to_string(),
|
||||
name: "Survival".to_string(),
|
||||
mc_version: "1.20.1".to_string(),
|
||||
loader: LoaderKind::Fabric,
|
||||
},
|
||||
Target {
|
||||
id: "modded".to_string(),
|
||||
name: "Modded".to_string(),
|
||||
mc_version: "1.21.1".to_string(),
|
||||
loader: LoaderKind::Forge,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// 四条命中项:**先造 `ContentHit` 再走 `Hit::of`**,保证演示数据与产品路径逐字段同源
|
||||
/// (自己拼 `Hit` 的那一版会把「1,200,000 下载量」「Fabric 首字母大写」这类格式化漂开)。
|
||||
fn hits(kind: ContentKind, catalog: Catalog, compatible_only: bool) -> Vec<Hit> {
|
||||
let rows: &[(&str, &str, &str)] = match kind {
|
||||
ContentKind::Modpack => &[
|
||||
(
|
||||
"Fabulously Optimized",
|
||||
"robotkoer",
|
||||
"Performance-focused vanilla+ pack",
|
||||
),
|
||||
("Adrenaline", "Someone", "Lightweight kitchen-sink pack"),
|
||||
("Better MC", "Luna", "Adventure pack with quests"),
|
||||
("All the Mods 9", "ATMTeam", "Kitchen sink, 400+ mods"),
|
||||
],
|
||||
ContentKind::Mod => &[
|
||||
("Sodium", "jellysquid3", "Modern rendering engine"),
|
||||
("Lithium", "jellysquid3", "Game logic optimizations"),
|
||||
("Iris Shaders", "coderbot", "Shader support for Fabric"),
|
||||
("JEI", "mezz", "Item and recipe viewer"),
|
||||
],
|
||||
ContentKind::Resourcepack => &[
|
||||
(
|
||||
"Faithful 32x",
|
||||
"Faithful",
|
||||
"Double resolution vanilla textures",
|
||||
),
|
||||
("Stay True", "Fred", "Warm, hand-drawn look"),
|
||||
("Classic 3D", "Someone", "3D blocks and items"),
|
||||
("Bare Bones", "Someone", "Flat, minimalist textures"),
|
||||
],
|
||||
ContentKind::Shader => &[
|
||||
("Complementary Reimagined", "Emin", "Balanced, high quality"),
|
||||
("BSL Shaders", "capttatsu", "Soft colors, strong weather"),
|
||||
("Photon", "SixthSurge", "Path-traced look"),
|
||||
("MakeUp Ultra Fast", "Someone", "Lightweight shader"),
|
||||
],
|
||||
};
|
||||
|
||||
rows.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (title, author, summary))| {
|
||||
// 前两条只支持 1.20.1 + Fabric(能装进 Survival);后两条是 1.21.1 + Forge
|
||||
let compatible = compatible_only || index < 2;
|
||||
Hit::of(
|
||||
&ContentHit {
|
||||
id: format!("{}-{index}", kind.slug()),
|
||||
slug: format!("{}-{index}", kind.slug()),
|
||||
title: (*title).to_string(),
|
||||
summary: (*summary).to_string(),
|
||||
author: (*author).to_string(),
|
||||
downloads: 1_200_000u64.saturating_sub(index as u64 * 111_111),
|
||||
updated: format!("2026-0{}-1{index}", index + 1),
|
||||
source: if index % 2 == 0 {
|
||||
ContentSource::Modrinth
|
||||
} else {
|
||||
ContentSource::Curseforge
|
||||
},
|
||||
game_versions: if compatible {
|
||||
vec!["1.20.1".to_string()]
|
||||
} else {
|
||||
vec!["1.21.1".to_string()]
|
||||
},
|
||||
loaders: if compatible {
|
||||
vec!["fabric".to_string()]
|
||||
} else {
|
||||
vec!["forge".to_string()]
|
||||
},
|
||||
icon: None,
|
||||
},
|
||||
catalog,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 装入中的面板(**复用下载页那块**:`pages::download::install_panel_model` + `install_panel`)。
|
||||
fn install_panel(catalog: Catalog) -> Download {
|
||||
use unlucky_presenter::dto::{InstallProgressView, InstallStage};
|
||||
let progress = InstallProgressView {
|
||||
version: "Sodium".to_string(),
|
||||
instance: "survival".to_string(),
|
||||
loader: "Fabric".to_string(),
|
||||
stage: InstallStage::Downloading,
|
||||
files_done: 0,
|
||||
files_total: 1,
|
||||
message: "sodium-fabric-0.6.0.jar".to_string(),
|
||||
..InstallProgressView::new()
|
||||
};
|
||||
crate::pages::download::install_panel_model(&progress, catalog)
|
||||
.expect("演示数据的进度是「下载中」,面板必然存在")
|
||||
}
|
||||
|
||||
/// 内建模型(默认 `ok` + 从环境变量读类型/状态)。
|
||||
pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
|
||||
let catalog = lang.catalog();
|
||||
let kind = std::env::var("ULL_CONTENT_KIND")
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
ContentKind::ALL
|
||||
.into_iter()
|
||||
.find(|item| item.slug() == value.trim())
|
||||
})
|
||||
.unwrap_or(fallback_kind);
|
||||
let state = std::env::var("ULL_CONTENT_STATE").unwrap_or_else(|_| "ok".to_string());
|
||||
|
||||
let targets = targets();
|
||||
let target = Some("survival".to_string());
|
||||
let empty = state == "empty";
|
||||
// 「装入中」那一屏:面板就是下载页那块(同一个函数),用来证明它在内容库语义下也可读
|
||||
let installing = state == "install";
|
||||
let list = if empty {
|
||||
Vec::new()
|
||||
} else {
|
||||
hits(kind, catalog, state != "incompatible")
|
||||
};
|
||||
// 「不兼容」那一屏:选中的那条是 1.21.1 + Forge 的项目,而目标是 1.20.1 + Fabric
|
||||
let selected_index = if state == "incompatible" { 2 } else { 0 };
|
||||
let selected = list.get(selected_index).map(|hit| hit.id.clone());
|
||||
let blockers = blockers_of(
|
||||
list.get(selected_index)
|
||||
.map(|hit| (hit.versions.as_slice(), hit.loaders.as_slice())),
|
||||
kind,
|
||||
&targets,
|
||||
target.as_deref(),
|
||||
);
|
||||
let target_label = targets
|
||||
.iter()
|
||||
.find(|item| Some(&item.id) == target.as_ref())
|
||||
.map(|item| item.label());
|
||||
|
||||
Content {
|
||||
kind,
|
||||
query: ContentQueryView {
|
||||
source: ContentSource::Modrinth,
|
||||
sort: ContentSort::Relevance,
|
||||
version: Some("1.20.1".to_string()),
|
||||
loader: Some(LoaderKind::Fabric),
|
||||
text: if empty {
|
||||
"quarkonium".to_string()
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
},
|
||||
hits: list,
|
||||
selected,
|
||||
status: if empty { Status::Empty } else { Status::Ready },
|
||||
total: if empty { 0 } else { 1_284 },
|
||||
// 演示数据故意把 `has_more` 置真(真实判据是「本页装满」):出图要能看到「加载更多」
|
||||
has_more: !empty,
|
||||
curseforge: false,
|
||||
targets,
|
||||
target: target.clone(),
|
||||
target_label,
|
||||
blockers,
|
||||
panel: if installing {
|
||||
Some(install_panel(catalog))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
versions: vec!["1.20.1".to_string(), "1.21.1".to_string()],
|
||||
draft: if empty {
|
||||
"quarkonium".to_string()
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//! 内容库页的栏几何(纯函数 + 数值表)。
|
||||
//!
|
||||
//! ## 为什么这一页自带一份几何
|
||||
//!
|
||||
//! 任务书要求「与 M107 同一套视觉语言」:类型栏 140 / 图标栏 64、间距 24、列表列下限 424
|
||||
//! (行宽红线)、详情 320。M107 的那份几何在 `pages/layout/rails.rs`(M107 分支,尚未并入 main),
|
||||
//! 本 mission 的分支基于 main,所以这里按**同一组数值**先落一份,并在报告里登记
|
||||
//! 「M107 合并后应改为复用 `pages/layout/rails.rs`,删掉本文件」。
|
||||
//!
|
||||
//! ## 四档的判据是**算出来的**,不是拍的
|
||||
//!
|
||||
//! | 档 | 条件(可用宽,已扣导航轨与页边距) | 版面 |
|
||||
//! |---|---|---|
|
||||
//! | `Wide` | ≥ 140 + 24 + 424 + 24 + 320 = **932** | 标签栏 + 列表 + 详情 |
|
||||
//! | `Narrow` | ≥ 64 + 24 + 424 + 24 + 320 = **856**(类型栏收成图标栏) | 图标栏 + 列表 + 详情 |
|
||||
//! | `Compact` | ≥ 140 + 24 + 424 = **588** | 标签栏 + 单列(详情整页替换列表) |
|
||||
//! | `Tight` | 其余 | 类型条变成顶部一行 chip + 单列 |
|
||||
//!
|
||||
//! 三个门槛都是「Σ栏宽 + 间距」算出来的,改动任何一档都会让数值表用例红。
|
||||
|
||||
use unlucky_design::spacing::Space;
|
||||
|
||||
use crate::pages::layout;
|
||||
|
||||
/// 类型栏宽(标签形态)。
|
||||
///
|
||||
/// M107 的版本类型栏是 140;这里取 200:四个**内容类型**的名字(`Resource packs`)比
|
||||
/// 版本类型(`Release` / `Snapshot`)长,140 会把标签切掉。登记在 `pages/content.rs` 的偏离表。
|
||||
pub const RAIL_LABEL: f32 = 200.0;
|
||||
/// 类型栏宽(图标形态)。
|
||||
pub const RAIL_ICON: f32 = 64.0;
|
||||
/// 详情面板宽。
|
||||
pub const DETAIL: f32 = 320.0;
|
||||
/// 栏间距(`Space::X6` = 24,与 `pages::layout::PANES_GAP` 同源)。
|
||||
pub const GAP: f32 = Space::X6.dp();
|
||||
|
||||
/// 一屏三栏各自多宽。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Rails {
|
||||
/// 档位
|
||||
pub tier: Tier,
|
||||
/// 类型栏宽(`Tight` 档是 0:类型条在顶部)
|
||||
pub rail: f32,
|
||||
/// 列表列宽
|
||||
pub list: f32,
|
||||
/// 详情面板宽(0 = 这一档没有独立详情列)
|
||||
pub detail: f32,
|
||||
/// 栏间距
|
||||
pub gap: f32,
|
||||
}
|
||||
|
||||
/// 版面档位。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Tier {
|
||||
/// 标签栏 + 列表 + 详情
|
||||
Wide,
|
||||
/// 图标栏 + 列表 + 详情
|
||||
Narrow,
|
||||
/// 标签栏 + 单列
|
||||
Compact,
|
||||
/// 顶部 chip 条 + 单列
|
||||
Tight,
|
||||
}
|
||||
|
||||
impl Tier {
|
||||
/// 这一档有独立详情列吗。
|
||||
pub const fn has_detail(self) -> bool {
|
||||
matches!(self, Self::Wide | Self::Narrow)
|
||||
}
|
||||
|
||||
/// 这一档的类型栏是标签形态吗(`Tight` 没有栏)。
|
||||
pub const fn rail_has_labels(self) -> bool {
|
||||
matches!(self, Self::Wide | Self::Compact)
|
||||
}
|
||||
}
|
||||
|
||||
/// `Wide` 的门槛:标签栏 + 间距 + 列表下限 + 间距 + 详情。
|
||||
pub fn wide_min() -> f32 {
|
||||
RAIL_LABEL + GAP + layout::LINE_SAFE_WIDTH_PX + GAP + DETAIL
|
||||
}
|
||||
|
||||
/// `Narrow` 的门槛:图标栏 + 间距 + 列表下限 + 间距 + 详情。
|
||||
pub fn narrow_min() -> f32 {
|
||||
RAIL_ICON + GAP + layout::LINE_SAFE_WIDTH_PX + GAP + DETAIL
|
||||
}
|
||||
|
||||
/// `Compact` 的门槛:标签栏 + 间距 + 列表下限。
|
||||
pub fn compact_min() -> f32 {
|
||||
RAIL_LABEL + GAP + layout::LINE_SAFE_WIDTH_PX
|
||||
}
|
||||
|
||||
/// 可用宽 → 三栏几何。
|
||||
pub fn resolve(available: f32) -> Rails {
|
||||
let cap = layout::measure_cap_px();
|
||||
|
||||
if available >= wide_min() {
|
||||
let rail = RAIL_LABEL;
|
||||
return Rails {
|
||||
tier: Tier::Wide,
|
||||
rail,
|
||||
gap: GAP,
|
||||
detail: DETAIL,
|
||||
list: (available - rail - GAP - DETAIL - GAP).max(layout::LINE_SAFE_WIDTH_PX),
|
||||
};
|
||||
}
|
||||
if available >= narrow_min() {
|
||||
let rail = RAIL_ICON;
|
||||
return Rails {
|
||||
tier: Tier::Narrow,
|
||||
rail,
|
||||
gap: GAP,
|
||||
detail: DETAIL,
|
||||
list: (available - rail - GAP - DETAIL - GAP).max(layout::LINE_SAFE_WIDTH_PX),
|
||||
};
|
||||
}
|
||||
if available >= compact_min() {
|
||||
let rail = RAIL_LABEL;
|
||||
return Rails {
|
||||
tier: Tier::Compact,
|
||||
rail,
|
||||
gap: GAP,
|
||||
detail: 0.0,
|
||||
list: (available - rail - GAP).min(cap.max(layout::LINE_SAFE_WIDTH_PX)),
|
||||
};
|
||||
}
|
||||
Rails {
|
||||
tier: Tier::Tight,
|
||||
rail: 0.0,
|
||||
gap: GAP,
|
||||
detail: 0.0,
|
||||
list: available.min(cap.max(layout::LINE_SAFE_WIDTH_PX)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 门槛必须由「Σ栏宽 + 间距」算出来(抓得住:谁把 932 抄成 900 这种拍值)。
|
||||
#[test]
|
||||
fn the_three_thresholds_are_sums_not_magic_numbers() {
|
||||
assert_eq!(wide_min(), 200.0 + 24.0 + 424.0 + 24.0 + 320.0);
|
||||
assert_eq!(narrow_min(), 64.0 + 24.0 + 424.0 + 24.0 + 320.0);
|
||||
assert_eq!(compact_min(), 200.0 + 24.0 + 424.0);
|
||||
assert_eq!(wide_min(), 992.0);
|
||||
assert_eq!(narrow_min(), 856.0);
|
||||
assert_eq!(compact_min(), 648.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_tier_takes_effect_at_its_own_threshold() {
|
||||
assert_eq!(resolve(wide_min()).tier, Tier::Wide);
|
||||
assert_eq!(resolve(wide_min() - 0.5).tier, Tier::Narrow);
|
||||
assert_eq!(resolve(narrow_min()).tier, Tier::Narrow);
|
||||
assert_eq!(resolve(narrow_min() - 0.5).tier, Tier::Compact);
|
||||
assert_eq!(resolve(compact_min()).tier, Tier::Compact);
|
||||
assert_eq!(resolve(compact_min() - 0.5).tier, Tier::Tight);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wide_page_never_gives_the_list_less_than_the_row_width_red_line() {
|
||||
// 1180 窗宽(右栗收起):1044 可用 → Wide,列表 536
|
||||
let rails = resolve(1044.0);
|
||||
assert_eq!(rails.tier, Tier::Wide);
|
||||
assert!(rails.list >= layout::LINE_SAFE_WIDTH_PX, "{}", rails.list);
|
||||
// 逐档扫一遍:只要还有独立列表列,就不许低于 424(行宽红线)
|
||||
for available in 500..=1600 {
|
||||
let rails = resolve(available as f32);
|
||||
if rails.rail > 0.0 {
|
||||
assert!(
|
||||
rails.list >= layout::LINE_SAFE_WIDTH_PX,
|
||||
"available={available} 时列表只剩 {}",
|
||||
rails.list
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_detail_column_only_exists_in_the_two_wide_tiers() {
|
||||
assert!(Tier::Wide.has_detail());
|
||||
assert!(Tier::Narrow.has_detail());
|
||||
assert!(!Tier::Compact.has_detail());
|
||||
assert!(!Tier::Tight.has_detail());
|
||||
assert_eq!(resolve(1200.0).detail, DETAIL);
|
||||
assert_eq!(resolve(700.0).detail, 0.0);
|
||||
// 图标形态只在这两档出现,且窄档的标签栏不存在
|
||||
assert_eq!(resolve(900.0).rail, RAIL_ICON);
|
||||
assert_eq!(resolve(700.0).rail, RAIL_LABEL);
|
||||
assert_eq!(resolve(400.0).rail, 0.0);
|
||||
assert!(Tier::Wide.rail_has_labels());
|
||||
assert!(!Tier::Narrow.rail_has_labels());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! 内容库页的**自有**消息(M109)。
|
||||
//!
|
||||
//! 与设置页同一个范式(`pages/settings/message.rs` + `pages::Message::Settings`):页面自己的一套
|
||||
//! 意图在这个文件里声明,`pages::Message` 只留一个 `Content(..)` 投递口,于是「加一条内容库消息」
|
||||
//! 不碰共享枚举的正文,也不碰 `app/update/page_messages.rs` 那条不许 `_ =>` 的 match。
|
||||
//!
|
||||
//! **一条纪律**:这里的每个变体都是**意图**,不是状态变更。改了条件 ≠ 已经发请求
|
||||
//! (`QueryChanged` 只改输入框里的字,`Search` 才发指令);点了「装入」≠ 已装上
|
||||
//! (装上的证据是 `InstallProgress` 事件回流到面板)。
|
||||
|
||||
use unlucky_presenter::dto::{ContentKind, ContentSort, ContentSource, LoaderKind};
|
||||
|
||||
/// 内容库页的消息。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
/// 换内容类型(左侧四条一级侧栏)
|
||||
SetKind(ContentKind),
|
||||
/// 关键词输入框变了(**只改草稿**,不 dispatch)
|
||||
QueryChanged(String),
|
||||
/// 提交搜索(零偏移重来)
|
||||
Search,
|
||||
/// 换来源(Modrinth / CurseForge)
|
||||
SetSource(ContentSource),
|
||||
/// 换排序
|
||||
SetSort(ContentSort),
|
||||
/// 换游戏版本过滤(`None` = 不限)
|
||||
SetVersion(Option<String>),
|
||||
/// 换加载器过滤(`None` = 不限)
|
||||
SetLoader(Option<LoaderKind>),
|
||||
/// 选中一个项目(**页面内选中**,不 dispatch)
|
||||
Select(String),
|
||||
/// 选目标实例(一键装入的对象)
|
||||
SetTarget(String),
|
||||
/// 加载下一页
|
||||
LoadMore,
|
||||
/// 同条件重试(失败态的出口)
|
||||
Retry,
|
||||
/// 装进目标实例
|
||||
Install,
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
//! 内容库页的视图模型:**presenter 快照 + 页面选中项 → 要画的东西**。
|
||||
//!
|
||||
//! ## 分层(与实例页 / 下载页同一条)
|
||||
//!
|
||||
//! | 数据源 | 谁转 | 用途 |
|
||||
//! |---|---|---|
|
||||
//! | `AppState::content`(检索条件 + 结果 + 请求态) | [`Content::from_snapshot`] | 产品运行时的真页面 |
|
||||
//! | [`super::demo`] 的固定夹具 | 同上 | `pages_demo` 出图与单测 |
|
||||
//!
|
||||
//! ## 「点之前就知道装不装得上」(任务书第 3 条)
|
||||
//!
|
||||
//! 兼容性判定在 [`Content::blockers`]:**纯函数**,输入是「选中的项目 + 目标实例」,
|
||||
//! 输出是 [`Blocker`] 列表。视图拿它决定按钮 `disabled` 与旁边那几句原因——于是「点完才报错」
|
||||
//! 在结构上不可能发生(按钮根本发不出消息)。
|
||||
//!
|
||||
//! 两条判据各自独立、**都能同时成立**(版本对不上 + 加载器也不对):把两条合成一条会在
|
||||
//! 「都不兼容」时只说一半原因。
|
||||
//!
|
||||
//! ## 为什么要 `PageState`(页面自留的那点状态)
|
||||
//!
|
||||
//! `kind` / `selected` / `target` / `draft` 都是**页面内**的选择:检索条件与结果在 presenter
|
||||
//! (`AppState::content`),这四样是「用户在这一屏上点了什么」,重建快照不该把它们冲掉。
|
||||
//! 它们的所有者是 `app/state.rs` 的 `App::content`,本文件只借用。
|
||||
|
||||
use unlucky_presenter::dto::{
|
||||
loader_facet, ContentKind, ContentResults, ContentSort, ContentSource, ContentState, LoaderKind,
|
||||
};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use unlucky_i18n::Catalog;
|
||||
|
||||
use crate::pages::download::Download;
|
||||
|
||||
mod items;
|
||||
|
||||
pub(crate) use items::{capitalize, thousands};
|
||||
pub use items::{loader_name, Blocker, Hit, PageState, Status, Target};
|
||||
|
||||
/// 内容库页要画的东西。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Content {
|
||||
/// 当前内容类型(左栏四条侧栏的选中项)
|
||||
pub kind: ContentKind,
|
||||
/// 当前检索条件(筛选条 + 分页游标)
|
||||
pub query: ContentQueryView,
|
||||
/// 命中项
|
||||
pub hits: Vec<Hit>,
|
||||
/// 选中项
|
||||
pub selected: Option<String>,
|
||||
/// 当前状态
|
||||
pub status: Status,
|
||||
/// 命中总数(`0` = 站点没给)
|
||||
pub total: u32,
|
||||
/// 还有下一页
|
||||
pub has_more: bool,
|
||||
/// CurseForge 源可用(缺 key 时筛选条里那条禁用并说明)
|
||||
pub curseforge: bool,
|
||||
/// 目标实例候选
|
||||
pub targets: Vec<Target>,
|
||||
/// 选中的目标实例
|
||||
pub target: Option<String>,
|
||||
/// 选中目标实例的可读标签(详情面板「装入到 …」)
|
||||
pub target_label: Option<String>,
|
||||
/// 装入不上的原因(空 = 可以装)
|
||||
pub blockers: Vec<Blocker>,
|
||||
/// 安装面板的视图模型(**复用下载页那块**:`pages::download::install_panel`)
|
||||
///
|
||||
/// 存整个 `Download` 而不是只存一帧视图:面板的入参就是 `&Download`,而 iced 的控件树
|
||||
/// 借用它 ⇒ 被借的东西必须活得比这一帧长(局部临时值不行)。
|
||||
pub panel: Option<Download>,
|
||||
/// 游戏版本筛选的候选(来自实例;去重、保持实例顺序)
|
||||
pub versions: Vec<String>,
|
||||
/// 关键词草稿
|
||||
pub draft: String,
|
||||
}
|
||||
|
||||
/// 筛选条的展示态(数字/枚举都在这里,页面不再从 `ContentQuery` 里挖字段)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ContentQueryView {
|
||||
/// 来源
|
||||
pub source: ContentSource,
|
||||
/// 排序
|
||||
pub sort: ContentSort,
|
||||
/// 游戏版本过滤
|
||||
pub version: Option<String>,
|
||||
/// 加载器过滤
|
||||
pub loader: Option<LoaderKind>,
|
||||
/// 已提交的关键词(输入框显示的是 `draft`)
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl Content {
|
||||
/// presenter 快照 + 页面选中项 → 视图模型(**唯一的真数据入口**)。
|
||||
pub fn from_snapshot(state: &AppState, page: &PageState, catalog: Catalog) -> Self {
|
||||
let content: &ContentState = &state.content;
|
||||
let hits: Vec<Hit> = content
|
||||
.results
|
||||
.as_ref()
|
||||
.map(|results| {
|
||||
results
|
||||
.hits
|
||||
.iter()
|
||||
.map(|hit| Hit::of(hit, catalog))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let status = status_of(content, &hits);
|
||||
let targets: Vec<Target> = state.instances.iter().map(Target::of).collect();
|
||||
let target = page
|
||||
.target
|
||||
.clone()
|
||||
.filter(|id| targets.iter().any(|target| &target.id == id))
|
||||
.or_else(|| targets.first().map(|target| target.id.clone()));
|
||||
let target_label = target
|
||||
.as_deref()
|
||||
.and_then(|id| targets.iter().find(|item| item.id == id))
|
||||
.map(Target::label);
|
||||
|
||||
let mut versions: Vec<String> = Vec::new();
|
||||
for instance in &targets {
|
||||
if !versions.iter().any(|item| item == &instance.mc_version) {
|
||||
versions.push(instance.mc_version.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let selected_hit = page.selected.as_deref().and_then(|id| {
|
||||
content
|
||||
.results
|
||||
.as_ref()?
|
||||
.hits
|
||||
.iter()
|
||||
.find(|hit| hit.id == id)
|
||||
});
|
||||
let blockers = blockers_of(
|
||||
selected_hit.map(|hit| (hit.game_versions.as_slice(), hit.loaders.as_slice())),
|
||||
page.kind,
|
||||
&targets,
|
||||
target.as_deref(),
|
||||
);
|
||||
|
||||
Self {
|
||||
kind: page.kind,
|
||||
query: ContentQueryView {
|
||||
source: content.query.source,
|
||||
sort: content.query.sort,
|
||||
version: content.query.game_version.clone(),
|
||||
loader: content.query.loader,
|
||||
text: content.query.text.clone(),
|
||||
},
|
||||
hits,
|
||||
selected: page.selected.clone(),
|
||||
status,
|
||||
total: content.results.as_ref().map_or(0, |results| results.total),
|
||||
has_more: content
|
||||
.results
|
||||
.as_ref()
|
||||
.is_some_and(ContentResults::has_more)
|
||||
&& !content.pending,
|
||||
curseforge: content
|
||||
.results
|
||||
.as_ref()
|
||||
.is_some_and(|results| results.curseforge),
|
||||
targets,
|
||||
target: target.clone(),
|
||||
target_label,
|
||||
blockers,
|
||||
panel: content_install(state, catalog),
|
||||
versions,
|
||||
draft: page.draft.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 选中项在命中列表里的那一行(详情面板读它)。
|
||||
pub fn selected_hit(&self) -> Option<&Hit> {
|
||||
let id = self.selected.as_deref()?;
|
||||
self.hits.iter().find(|hit| hit.id == id)
|
||||
}
|
||||
|
||||
/// 「装入」按钮此刻能不能按(`false` 时按钮禁用,`blockers()` 给原因)。
|
||||
pub fn can_install(&self) -> bool {
|
||||
self.selected_hit().is_some() && self.blockers.is_empty()
|
||||
}
|
||||
|
||||
/// 原因的人读文案(顺序 = 判定顺序;空 = 没有原因)。
|
||||
pub fn blocker_reasons(&self, catalog: Catalog) -> Vec<String> {
|
||||
self.blockers
|
||||
.iter()
|
||||
.map(|blocker| blocker.reason(catalog))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 加载中(首屏 / 换条件)。
|
||||
pub const fn is_loading(&self) -> bool {
|
||||
matches!(self.status, Status::Loading)
|
||||
}
|
||||
}
|
||||
|
||||
/// 快照 → 四态。判据只有三个字段(请求在飞 / 失败原因 / 命中项),没有第四份真相。
|
||||
fn status_of(content: &ContentState, hits: &[Hit]) -> Status {
|
||||
if let Some(reason) = &content.failed {
|
||||
return Status::Failed(reason.clone());
|
||||
}
|
||||
if content.pending || content.results.is_none() {
|
||||
return Status::Loading;
|
||||
}
|
||||
if hits.is_empty() {
|
||||
Status::Empty
|
||||
} else {
|
||||
Status::Ready
|
||||
}
|
||||
}
|
||||
|
||||
/// 安装面板(**只借用下载页那块面板**,不另写一套画法)。
|
||||
///
|
||||
/// 转换与「成功/取消这类终态不占面板」的判断都在 `pages::download::install_panel_model` 里
|
||||
/// (M105 起 `InstallView` 不再从那个模块导出):内容库只拿 `Option<Download>`。
|
||||
fn content_install(state: &AppState, catalog: Catalog) -> Option<Download> {
|
||||
state
|
||||
.install
|
||||
.as_ref()
|
||||
.and_then(|progress| crate::pages::download::install_panel_model(progress, catalog))
|
||||
}
|
||||
|
||||
/// 兼容性判定(**纯函数**,单测直接钉它;见模块文档第 2 节)。
|
||||
///
|
||||
/// `hit` 是「选中项的两个支持列表」:`None` = 没选中,`Some((版本, 加载器))` = 选中了。
|
||||
/// **空列表 ≠ 不兼容**:站点没给数据时按「未知」处理,不许把「不知道」画成「不支持」。
|
||||
pub(super) fn blockers_of(
|
||||
hit: Option<(&[String], &[String])>,
|
||||
kind: ContentKind,
|
||||
targets: &[Target],
|
||||
target: Option<&str>,
|
||||
) -> Vec<Blocker> {
|
||||
if targets.is_empty() {
|
||||
return vec![Blocker::NoTarget];
|
||||
}
|
||||
let Some(target) = target.and_then(|id| targets.iter().find(|item| item.id == id)) else {
|
||||
return vec![Blocker::NoTarget];
|
||||
};
|
||||
let mut blockers = Vec::new();
|
||||
// 整合包:不是「兼容不兼容」的问题,是这条路根本没有(M111)
|
||||
if kind.dir().is_none() {
|
||||
blockers.push(Blocker::NeedsPipeline);
|
||||
}
|
||||
let Some((versions, loaders)) = hit else {
|
||||
return blockers;
|
||||
};
|
||||
if !versions.is_empty() && !versions.iter().any(|item| item == &target.mc_version) {
|
||||
blockers.push(Blocker::Version {
|
||||
needs: join_or_unknown(versions),
|
||||
actual: target.mc_version.clone(),
|
||||
});
|
||||
}
|
||||
let facet = loader_facet(target.loader);
|
||||
if let Some(facet) = facet {
|
||||
if !loaders.is_empty() && !loaders.iter().any(|item| item == facet) {
|
||||
blockers.push(Blocker::Loader {
|
||||
loader: target.loader,
|
||||
});
|
||||
}
|
||||
}
|
||||
blockers
|
||||
}
|
||||
|
||||
/// 版本列表 → 一行人读串(最多三个,其余用 `…`;空 = 「未知」)。
|
||||
fn join_or_unknown(versions: &[String]) -> String {
|
||||
if versions.is_empty() {
|
||||
return "?".to_string();
|
||||
}
|
||||
let shown = versions.iter().take(3).cloned().collect::<Vec<_>>();
|
||||
if versions.len() > shown.len() {
|
||||
format!("{}…", shown.join(", "))
|
||||
} else {
|
||||
shown.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "model/tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,218 @@
|
||||
//! 内容库页的类型:页面自留状态 / 四态 / 展示行 / 目标实例 / 装不上的原因。
|
||||
//!
|
||||
//! 从 `pages/content/model.rs` 拆出(那个文件已贴 400 行硬上限)。纯数据类型与纯函数,
|
||||
//! 不认识 iced:`model/tests.rs` 直接钉它们的判定。
|
||||
|
||||
use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSource, InstanceSummary, LoaderKind};
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
|
||||
/// 页面自留的选择(检索条件与结果在 presenter 的 `AppState::content`)。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PageState {
|
||||
/// 当前内容类型
|
||||
pub kind: ContentKind,
|
||||
/// 选中的项目 id(`None` = 没选 → 详情面板空态)
|
||||
pub selected: Option<String>,
|
||||
/// 目标实例 id(`None` = 没选 → 装入按钮禁用并说明)
|
||||
pub target: Option<String>,
|
||||
/// 关键词输入框里的草稿(**未提交**;提交后才进 `AppState::content.query`)
|
||||
pub draft: String,
|
||||
}
|
||||
|
||||
impl Default for PageState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
kind: ContentKind::Mod,
|
||||
selected: None,
|
||||
target: None,
|
||||
draft: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面此刻画哪一态(四态 + 分页尾)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
/// 加载中(首屏 / 换条件 / 换页)
|
||||
Loading,
|
||||
/// 有命中项
|
||||
Ready,
|
||||
/// 拉到了但一条都没有
|
||||
Empty,
|
||||
/// 失败(带一句人读原因)
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl Status {
|
||||
/// 失败了吗(视图用它决定画错误面板还是列表)。
|
||||
pub const fn failed(&self) -> bool {
|
||||
matches!(self, Self::Failed(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条命中项的展示态(文案在这一层求好:iced 的文本控件借用内容)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Hit {
|
||||
/// 项目 id(选中 / 装入指令的载荷)
|
||||
pub id: String,
|
||||
/// 标题
|
||||
pub title: String,
|
||||
/// 一行说明
|
||||
pub summary: String,
|
||||
/// 来源 + 作者(`Modrinth · jelly`)
|
||||
pub supporting: String,
|
||||
/// 下载量(已加千分位)
|
||||
pub downloads: String,
|
||||
/// 更新日期的人读串
|
||||
pub updated: String,
|
||||
/// 支持的游戏版本(详情面板用;空 = 站点没给)
|
||||
pub versions: Vec<String>,
|
||||
/// 支持的加载器(**站点原样的小写 facet**:兼容性判定要用它跟 `loader_facet` 比)
|
||||
pub loaders: Vec<String>,
|
||||
/// 来源(行上标来源徽标)
|
||||
pub source: ContentSource,
|
||||
}
|
||||
|
||||
impl Hit {
|
||||
/// 命中项 DTO → 展示态(**演示数据也走这条**,于是 fixture 与产品路径逐字段同源)。
|
||||
pub(crate) fn of(hit: &ContentHit, catalog: Catalog) -> Self {
|
||||
let source = match hit.source {
|
||||
ContentSource::Modrinth => catalog.text(Key::ContentSourceModrinth),
|
||||
ContentSource::Curseforge => catalog.text(Key::ContentSourceCurseforge),
|
||||
};
|
||||
let supporting = if hit.author.is_empty() {
|
||||
source.to_string()
|
||||
} else {
|
||||
format!("{source} · {}", hit.author)
|
||||
};
|
||||
Self {
|
||||
id: hit.id.clone(),
|
||||
title: hit.title.clone(),
|
||||
summary: hit.summary.clone(),
|
||||
supporting,
|
||||
downloads: catalog.render(
|
||||
Key::ContentDetailDownloads,
|
||||
&[("count", &thousands(hit.downloads))],
|
||||
),
|
||||
updated: catalog.render(Key::ContentDetailUpdated, &[("date", &hit.updated)]),
|
||||
versions: hit.game_versions.clone(),
|
||||
loaders: hit.loaders.clone(),
|
||||
source: hit.source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 目标实例(一键装入的对象)的展示态。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Target {
|
||||
/// 实例 id
|
||||
pub id: String,
|
||||
/// 显示名
|
||||
pub name: String,
|
||||
/// 游戏版本
|
||||
pub mc_version: String,
|
||||
/// 加载器
|
||||
pub loader: LoaderKind,
|
||||
}
|
||||
|
||||
impl Target {
|
||||
pub(super) fn of(instance: &InstanceSummary) -> Self {
|
||||
Self {
|
||||
id: instance.id.clone(),
|
||||
name: instance.name.clone(),
|
||||
mc_version: instance.mc_version.clone(),
|
||||
loader: instance.loader.kind,
|
||||
}
|
||||
}
|
||||
|
||||
/// 详情里那一行(`Survival · 1.20.4 · Fabric`)。
|
||||
pub(crate) fn label(&self) -> String {
|
||||
format!(
|
||||
"{} · {} · {}",
|
||||
self.name,
|
||||
self.mc_version,
|
||||
loader_name(self.loader)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载量加千分位(`1200000` → `1,200,000`):raw 数字在详情面板上读不出量级。
|
||||
pub(crate) fn thousands(value: u64) -> String {
|
||||
let digits = value.to_string();
|
||||
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
|
||||
for (index, ch) in digits.chars().enumerate() {
|
||||
if index > 0 && (digits.len() - index).is_multiple_of(3) {
|
||||
out.push(',');
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 首字母大写(`fabric` → `Fabric`;空串原样)。
|
||||
///
|
||||
/// **只在画的时候用**:`Hit::loaders` 与兼容性判定都保持站点原样的小写 facet,
|
||||
/// 否则「Fabric」与 facet「fabric」一比就成了不兼容(本轮踩过)。
|
||||
pub(crate) fn capitalize(raw: &str) -> String {
|
||||
let mut chars = raw.chars();
|
||||
match chars.next() {
|
||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载器的**界面名**(ASCII)。
|
||||
///
|
||||
/// 不复用 `LoaderKind::label()`:它对原版返回「原版」——中文业务串,而本仓的界面文案在 i18n
|
||||
/// 之前是英文基线(干净 CI 上没有中文字体,见 `pages/instances/data.rs` 的同一条理由)。
|
||||
pub const fn loader_name(loader: LoaderKind) -> &'static str {
|
||||
match loader {
|
||||
LoaderKind::Vanilla => "Vanilla",
|
||||
LoaderKind::Fabric => "Fabric",
|
||||
LoaderKind::Quilt => "Quilt",
|
||||
LoaderKind::Forge => "Forge",
|
||||
LoaderKind::NeoForge => "NeoForge",
|
||||
LoaderKind::OptiFine => "OptiFine",
|
||||
LoaderKind::LiteLoader => "LiteLoader",
|
||||
}
|
||||
}
|
||||
|
||||
/// 装不上的原因(**一条一个原因**,可以有多条同时成立)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Blocker {
|
||||
/// 整包类内容不走「单文件进目录」这条路(M111 才有导入管线)
|
||||
NeedsPipeline,
|
||||
/// 项目不支持目标实例的游戏版本
|
||||
Version {
|
||||
/// 项目支持的游戏版本(人读,最多取三个)
|
||||
needs: String,
|
||||
/// 目标实例的版本
|
||||
actual: String,
|
||||
},
|
||||
/// 项目不支持目标实例的加载器
|
||||
Loader {
|
||||
/// 目标实例的加载器
|
||||
loader: LoaderKind,
|
||||
},
|
||||
/// 没有目标实例
|
||||
NoTarget,
|
||||
}
|
||||
|
||||
impl Blocker {
|
||||
/// 一句话原因(i18n)。
|
||||
pub(super) fn reason(&self, catalog: Catalog) -> String {
|
||||
match self {
|
||||
Self::NeedsPipeline => catalog.text(Key::ContentModpackNotReady).to_string(),
|
||||
Self::Version { needs, actual } => catalog.render(
|
||||
Key::ContentIncompatibleVersion,
|
||||
&[("version", needs), ("actual", actual)],
|
||||
),
|
||||
Self::Loader { loader } => catalog.render(
|
||||
Key::ContentIncompatibleLoader,
|
||||
&[("loader", loader_name(*loader))],
|
||||
),
|
||||
Self::NoTarget => catalog.text(Key::ContentNoInstances).to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//! 内容库视图模型的守门测试(M109):四态判定、兼容性判定、装入闸门。
|
||||
//!
|
||||
//! 全部是纯函数级断言(不开图形环境):`status_of` / `blockers_of` / `Content::can_install`
|
||||
//! 是页面「该画什么、按钮能不能按」的唯一判据,钉住它们比截图更早期。
|
||||
|
||||
use super::*;
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dto::{
|
||||
ContentHit, ContentKind, ContentQuery, ContentResults, ContentSource, InstanceRuntime,
|
||||
LoaderKind,
|
||||
};
|
||||
|
||||
fn hit(versions: &[&str], loaders: &[&str]) -> ContentHit {
|
||||
ContentHit {
|
||||
id: "p1".to_string(),
|
||||
slug: "p".to_string(),
|
||||
title: "Sodium".to_string(),
|
||||
summary: "fast".to_string(),
|
||||
author: "jelly".to_string(),
|
||||
downloads: 10,
|
||||
updated: "2026-01-01".to_string(),
|
||||
source: ContentSource::Modrinth,
|
||||
game_versions: versions.iter().map(|item| (*item).to_string()).collect(),
|
||||
loaders: loaders.iter().map(|item| (*item).to_string()).collect(),
|
||||
icon: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn targets() -> Vec<Target> {
|
||||
vec![
|
||||
Target {
|
||||
id: "fabric-1.20.1".to_string(),
|
||||
name: "Survival".to_string(),
|
||||
mc_version: "1.20.1".to_string(),
|
||||
loader: LoaderKind::Fabric,
|
||||
},
|
||||
Target {
|
||||
id: "forge-1.21.1".to_string(),
|
||||
name: "Modded".to_string(),
|
||||
mc_version: "1.21.1".to_string(),
|
||||
loader: LoaderKind::Forge,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn blockers_of_hit(hit: &ContentHit, kind: ContentKind, target: Option<&str>) -> Vec<Blocker> {
|
||||
blockers_of(
|
||||
Some((hit.game_versions.as_slice(), hit.loaders.as_slice())),
|
||||
kind,
|
||||
&targets(),
|
||||
target,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_matching_project_has_no_blockers() {
|
||||
let hit = hit(&["1.20.1"], &["fabric"]);
|
||||
assert!(blockers_of_hit(&hit, ContentKind::Mod, Some("fabric-1.20.1")).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_version_mismatch_is_reported_before_the_user_clicks() {
|
||||
// 抓得住:把「版本对不上」留到点了才报错(任务书第 3 条禁止的正是这个)。
|
||||
let hit = hit(&["1.20.1"], &["fabric"]);
|
||||
let blockers = blockers_of_hit(&hit, ContentKind::Mod, Some("forge-1.21.1"));
|
||||
assert!(blockers.contains(&Blocker::Version {
|
||||
needs: "1.20.1".to_string(),
|
||||
actual: "1.21.1".to_string()
|
||||
}));
|
||||
assert!(blockers.contains(&Blocker::Loader {
|
||||
loader: LoaderKind::Forge
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_mismatches_are_reported_separately() {
|
||||
// 合成一条会在「都不兼容」时只说一半原因
|
||||
let hit = hit(&["1.20.1"], &["fabric"]);
|
||||
assert_eq!(
|
||||
blockers_of_hit(&hit, ContentKind::Mod, Some("forge-1.21.1")).len(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn site_data_that_is_missing_never_blocks() {
|
||||
// 站点没给版本/加载器 = 未知,不是「不支持」
|
||||
let hit = hit(&[], &[]);
|
||||
assert!(blockers_of_hit(&hit, ContentKind::Mod, Some("forge-1.21.1")).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modpacks_are_blocked_by_the_missing_pipeline_not_by_compatibility() {
|
||||
let hit = hit(&["1.20.1"], &["fabric"]);
|
||||
assert_eq!(
|
||||
blockers_of_hit(&hit, ContentKind::Modpack, Some("fabric-1.20.1")),
|
||||
vec![Blocker::NeedsPipeline]
|
||||
);
|
||||
// 资源包 / 光影 / Mod 都没有这一条
|
||||
for kind in [
|
||||
ContentKind::Mod,
|
||||
ContentKind::Resourcepack,
|
||||
ContentKind::Shader,
|
||||
] {
|
||||
assert!(blockers_of_hit(&hit, kind, Some("fabric-1.20.1")).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_target_instance_disables_the_button_with_a_reason() {
|
||||
assert_eq!(
|
||||
blockers_of(None, ContentKind::Mod, &[], None),
|
||||
vec![Blocker::NoTarget]
|
||||
);
|
||||
// 有实例但选中项不在名单里(被删了):同样不许静默通过
|
||||
assert_eq!(
|
||||
blockers_of(None, ContentKind::Mod, &targets(), Some("gone")),
|
||||
vec![Blocker::NoTarget]
|
||||
);
|
||||
// 没选中项目:只剩「目标」与「整包」这两类先天原因
|
||||
assert!(blockers_of(None, ContentKind::Mod, &targets(), Some("fabric-1.20.1")).is_empty());
|
||||
}
|
||||
|
||||
fn state_with(results: Option<ContentResults>, pending: bool) -> AppState {
|
||||
let mut state = AppState::new();
|
||||
state.instances = Vec::new();
|
||||
state.content.pending = pending;
|
||||
state.content.results = results;
|
||||
state
|
||||
}
|
||||
|
||||
fn results(hits: Vec<ContentHit>, offset: u32) -> ContentResults {
|
||||
let mut query = ContentQuery::first(ContentKind::Mod);
|
||||
query.offset = offset;
|
||||
ContentResults {
|
||||
query,
|
||||
hits,
|
||||
total: 100,
|
||||
curseforge: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_four_states_are_derived_from_three_fields() {
|
||||
let page = PageState::default();
|
||||
let catalog = Lang::En.catalog();
|
||||
|
||||
// 还没有结果 ⇒ 加载
|
||||
let loading = Content::from_snapshot(&state_with(None, false), &page, catalog);
|
||||
assert!(loading.is_loading());
|
||||
assert_eq!(loading.status, Status::Loading);
|
||||
|
||||
// 请求在飞 ⇒ 加载(即使手里还有上一批)
|
||||
let pending = Content::from_snapshot(
|
||||
&state_with(Some(results(vec![hit(&["1.20.1"], &["fabric"])], 0)), true),
|
||||
&page,
|
||||
catalog,
|
||||
);
|
||||
assert_eq!(pending.status, Status::Loading);
|
||||
|
||||
// 拉到了但零命中 ⇒ 空
|
||||
let empty = Content::from_snapshot(
|
||||
&state_with(Some(results(Vec::new(), 0)), false),
|
||||
&page,
|
||||
catalog,
|
||||
);
|
||||
assert_eq!(empty.status, Status::Empty);
|
||||
|
||||
// 有命中 ⇒ 有数据
|
||||
let ready = Content::from_snapshot(
|
||||
&state_with(Some(results(vec![hit(&["1.20.1"], &["fabric"])], 0)), false),
|
||||
&page,
|
||||
catalog,
|
||||
);
|
||||
assert_eq!(ready.status, Status::Ready);
|
||||
assert_eq!(ready.hits.len(), 1);
|
||||
assert_eq!(ready.hits[0].title, "Sodium");
|
||||
assert!(!ready.has_more, "只有 1 条、不够一页 ⇒ 没有下一页");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failure_outranks_the_loading_state() {
|
||||
let mut state = state_with(None, true);
|
||||
state.content.failed = Some("网络不通".to_string());
|
||||
let model = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog());
|
||||
assert_eq!(model.status, Status::Failed("网络不通".to_string()));
|
||||
assert!(model.status.failed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_install_needs_a_selection_and_no_blockers() {
|
||||
let mut state = state_with(Some(results(vec![hit(&["1.20.1"], &["fabric"])], 0)), false);
|
||||
state.instances = vec![unlucky_presenter::dto::InstanceSummary {
|
||||
id: "fabric-1.20.1".to_string(),
|
||||
name: "Survival".to_string(),
|
||||
mc_version: "1.20.1".to_string(),
|
||||
loader: unlucky_presenter::dto::LoaderInfo {
|
||||
kind: LoaderKind::Fabric,
|
||||
version: "0.16.5".to_string(),
|
||||
},
|
||||
last_played: None,
|
||||
runtime: InstanceRuntime::Idle,
|
||||
icon: None,
|
||||
isolation: "isolated".to_string(),
|
||||
version_id: "1.20.1".to_string(),
|
||||
version_present: true,
|
||||
}];
|
||||
let catalog = Lang::En.catalog();
|
||||
let page = PageState {
|
||||
kind: ContentKind::Mod,
|
||||
selected: None,
|
||||
target: None,
|
||||
draft: String::new(),
|
||||
};
|
||||
|
||||
// 没选中项目 ⇒ 不能装(也不该说「成功」)
|
||||
let no_selection = Content::from_snapshot(&state, &page, catalog);
|
||||
assert!(!no_selection.can_install());
|
||||
assert_eq!(
|
||||
no_selection.target.as_deref(),
|
||||
Some("fabric-1.20.1"),
|
||||
"只有一个实例就默认选它"
|
||||
);
|
||||
|
||||
// 选中兼容项 ⇒ 能装
|
||||
let page = PageState {
|
||||
selected: Some("p1".to_string()),
|
||||
..page
|
||||
};
|
||||
let compatible = Content::from_snapshot(&state, &page, catalog);
|
||||
assert!(compatible.can_install());
|
||||
assert!(compatible.blocker_reasons(catalog).is_empty());
|
||||
assert!(compatible.target_label.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_panel_only_shows_while_an_install_is_unfinished() {
|
||||
use unlucky_presenter::dto::{InstallProgressView, InstallStage};
|
||||
|
||||
let mut state = state_with(None, false);
|
||||
state.install = Some(InstallProgressView {
|
||||
version: "Sodium".to_string(),
|
||||
stage: InstallStage::Downloading,
|
||||
files_total: 1,
|
||||
..InstallProgressView::new()
|
||||
});
|
||||
let running = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog());
|
||||
assert!(running.panel.is_some(), "下载中要有面板");
|
||||
|
||||
state.install = Some(InstallProgressView {
|
||||
stage: InstallStage::Done,
|
||||
..InstallProgressView::new()
|
||||
});
|
||||
let done = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog());
|
||||
assert!(done.panel.is_none(), "装完的面板不该钉在页面上");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loader_names_are_ascii_so_the_english_baseline_stays_english() {
|
||||
for loader in [
|
||||
LoaderKind::Vanilla,
|
||||
LoaderKind::Fabric,
|
||||
LoaderKind::Forge,
|
||||
LoaderKind::NeoForge,
|
||||
LoaderKind::Quilt,
|
||||
] {
|
||||
assert!(loader_name(loader).is_ascii(), "{loader:?}");
|
||||
}
|
||||
assert_eq!(
|
||||
loader_name(LoaderKind::Vanilla),
|
||||
"Vanilla",
|
||||
"不能画「原版」"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
//! 内容库页的零件:筛选条 / 结果列表 / 详情面板 / 四态(加载 / 空 / 失败 / 无更多)/ 一级切换。
|
||||
//!
|
||||
//! 全是既有真组件(`chip` / `list_item` / `buttons` / `empty_state`),页面不自己画状态层。
|
||||
//!
|
||||
//! ## 消息口径
|
||||
//!
|
||||
//! 本文件的每个控件都直接发 [`crate::pages::Message`]:内容库自己的意图包一层
|
||||
//! `PageMessage::Content(..)`([`intent`]),而「取消安装 / 打开日志」用的是**下载页就有的**
|
||||
//! 那两个变体——因为对用户来说它们是同一件事(同一个 `AppCommand::CancelInstall`)。
|
||||
|
||||
use iced::widget::{column, container, row, scrollable};
|
||||
use iced::{Alignment, Element, Length, Padding};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::ContentSort;
|
||||
use unlucky_presenter::router::Route;
|
||||
|
||||
use crate::app::components::buttons::{self, Button};
|
||||
use crate::app::components::feedback::{empty_state, EmptyState};
|
||||
use crate::app::components::navigation::list_item::{list_item, ListItem};
|
||||
use crate::app::components::selection::{chip, Chip};
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::layout::Rails;
|
||||
use super::model::{Content, Hit, Status};
|
||||
|
||||
mod detail;
|
||||
mod filters;
|
||||
|
||||
pub(crate) use detail::detail;
|
||||
pub(crate) use filters::{filters, labelled};
|
||||
|
||||
use super::Message as ContentMessage;
|
||||
use crate::pages::frame;
|
||||
use crate::pages::Message as PageMessage;
|
||||
|
||||
/// 内容库意图 → 页面消息(唯一的一层包装)。
|
||||
pub(super) fn intent(message: ContentMessage) -> PageMessage {
|
||||
PageMessage::Content(message)
|
||||
}
|
||||
|
||||
/// 结果行与行之间的间距(与下载页同值)。
|
||||
const ROW_GAP: Space = Space::X1;
|
||||
|
||||
/// 一级切换:版本管理 | 内容库(M109 按 B1 裁定:共用同一条导航轨项,页内切换)。
|
||||
pub(super) fn section_switcher<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
on_content: bool,
|
||||
) -> Element<'a, PageMessage> {
|
||||
let versions = Chip::filter(
|
||||
catalog.text(Key::ContentSectionVersions),
|
||||
!on_content,
|
||||
PageMessage::Navigate(Route::Download),
|
||||
);
|
||||
let content = Chip::filter(
|
||||
catalog.text(Key::ContentSectionContent),
|
||||
on_content,
|
||||
PageMessage::Navigate(Route::Content),
|
||||
);
|
||||
row![chip(ui, versions), chip(ui, content)]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 关键词搜索条(输入只改草稿;回车/按钮才发检索指令)。
|
||||
pub(super) fn search<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
model: &'a Content,
|
||||
) -> Element<'a, PageMessage> {
|
||||
// 输入只改草稿,**提交**才发检索(每敲一个字就打一次网络既没必要也会被限流)。
|
||||
// 搜索条自身没有提交回调,所以旁边给一个显式的「搜索」按钮——这也是键盘外的唯一出口。
|
||||
let bar = crate::app::components::inputs::search_bar(
|
||||
ui,
|
||||
crate::app::components::inputs::SearchBar::new(model.draft.as_str())
|
||||
.placeholder(catalog.text(Key::ContentSearchPlaceholder)),
|
||||
move |text| intent(ContentMessage::QueryChanged(text)),
|
||||
);
|
||||
let submit = crate::app::components::buttons::text(
|
||||
ui,
|
||||
crate::app::components::buttons::Button::new(
|
||||
catalog.text(Key::ContentSearch),
|
||||
intent(ContentMessage::Search),
|
||||
)
|
||||
.leading(IconKind::Search),
|
||||
);
|
||||
row![bar, submit]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 结果列表(独立滚动;底部「加载更多」钉在列表之外,见 [`more`])。
|
||||
pub(super) fn list<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
model: &'a Content,
|
||||
rails: Rails,
|
||||
) -> Element<'a, PageMessage> {
|
||||
let rows = column(
|
||||
model
|
||||
.hits
|
||||
.iter()
|
||||
.map(|hit| hit_row(ui, model, hit))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.spacing(ui.space(ROW_GAP))
|
||||
.padding(Padding::from([0.0, ui.space(Space::X2)]))
|
||||
.width(Length::Fill);
|
||||
|
||||
let _ = catalog;
|
||||
container(
|
||||
scrollable(rows)
|
||||
.direction(scrollable::Direction::Vertical(
|
||||
scrollable::Scrollbar::new()
|
||||
.width(frame::SCROLLBAR_WIDTH)
|
||||
.scroller_width(frame::SCROLLBAR_WIDTH)
|
||||
.margin(frame::SCROLLBAR_MARGIN),
|
||||
))
|
||||
.height(Length::Fill)
|
||||
.style(frame::scroll_style(ui)),
|
||||
)
|
||||
.width(Length::Fixed(rails.list))
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn hit_row<'a>(ui: &'a Ui, model: &Content, hit: &'a Hit) -> Element<'a, PageMessage> {
|
||||
let mut item = ListItem::new(
|
||||
hit.title.as_str(),
|
||||
intent(ContentMessage::Select(hit.id.clone())),
|
||||
)
|
||||
.overline(hit.supporting.as_str())
|
||||
.supporting(hit.summary.as_str())
|
||||
.leading(kind_icon(model.kind));
|
||||
if model.selected.as_deref() == Some(hit.id.as_str()) {
|
||||
item = item.selected();
|
||||
}
|
||||
list_item(ui, item)
|
||||
}
|
||||
|
||||
/// 计数头 + 翻页尾(分页而非无限滚动:见 `pages/content.rs` 的模块文档)。
|
||||
pub(super) fn count_head<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
model: &Content,
|
||||
) -> Element<'a, PageMessage> {
|
||||
let count = if model.total > 0 {
|
||||
model.total
|
||||
} else {
|
||||
u32::try_from(model.hits.len()).unwrap_or(u32::MAX)
|
||||
};
|
||||
frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
catalog.render(
|
||||
Key::ContentCount,
|
||||
&[("count", &super::model::thousands(u64::from(count)))],
|
||||
),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)
|
||||
}
|
||||
|
||||
/// 列表尾部:还有更多 ⇒「加载更多」;没有了 ⇒ 一句「没有更多了」。
|
||||
pub(super) fn more<'a>(ui: &'a Ui, catalog: Catalog, model: &Content) -> Element<'a, PageMessage> {
|
||||
if model.has_more {
|
||||
return buttons::text(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::ContentLoadMore),
|
||||
intent(ContentMessage::LoadMore),
|
||||
)
|
||||
.leading(IconKind::ChevronDown),
|
||||
);
|
||||
}
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.text(Key::ContentNoMore),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn loading<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
|
||||
centered(
|
||||
column![
|
||||
frame::text_role_centered(
|
||||
ui,
|
||||
TypeRole::TitleMedium,
|
||||
true,
|
||||
catalog.text(Key::ContentLoading),
|
||||
ui.color(Role::OnSurface)
|
||||
),
|
||||
frame::text_role_centered(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.text(Key::ContentLoadingHint),
|
||||
ui.color(Role::OnSurfaceVariant)
|
||||
),
|
||||
buttons::text(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::ContentRetry),
|
||||
intent(ContentMessage::Retry)
|
||||
)
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X3))
|
||||
.width(Length::Fill)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
}
|
||||
|
||||
/// 空态(真 `EmptyState`:CTA 是标准 Filled 按钮)。
|
||||
pub(super) fn empty<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
|
||||
centered(empty_state(
|
||||
ui,
|
||||
EmptyState::new(
|
||||
IconKind::Search,
|
||||
catalog.text(Key::ContentEmptyTitle),
|
||||
catalog.text(Key::ContentEmptyBody),
|
||||
)
|
||||
.action(
|
||||
catalog.text(Key::ContentRetry),
|
||||
intent(ContentMessage::Retry),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
/// 失败态(带原因与重试出口;原因来自 host,不打码不打折)。
|
||||
pub(super) fn failed<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
reason: &'a str,
|
||||
) -> Element<'a, PageMessage> {
|
||||
centered(
|
||||
column![
|
||||
row![
|
||||
frame::glyph(IconKind::Error, ui.color(Role::Error), frame::ICON_PX),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::TitleSmall,
|
||||
true,
|
||||
catalog.text(Key::ContentFailedTitle),
|
||||
ui.color(Role::Error)
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
reason,
|
||||
ui.color(Role::OnSurfaceVariant)
|
||||
),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.text(Key::ContentFailedBody),
|
||||
ui.color(Role::OnSurfaceVariant)
|
||||
),
|
||||
buttons::filled(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::ContentRetry),
|
||||
intent(ContentMessage::Retry)
|
||||
)
|
||||
.leading(IconKind::Refresh)
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X3))
|
||||
.width(Length::Fill)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
}
|
||||
|
||||
fn centered<'a>(content: impl Into<Element<'a, PageMessage>>) -> Element<'a, PageMessage> {
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 内容类型 → 图标(**借用**现有字形:图标集里还没有「整合包/Mod/资源包/光影」四枚专用图标,
|
||||
/// 已在报告里登记为偏离)。
|
||||
pub(super) const fn kind_icon(kind: unlucky_presenter::dto::ContentKind) -> IconKind {
|
||||
match kind {
|
||||
unlucky_presenter::dto::ContentKind::Modpack => IconKind::Folder,
|
||||
unlucky_presenter::dto::ContentKind::Mod => IconKind::Instances,
|
||||
unlucky_presenter::dto::ContentKind::Resourcepack => IconKind::Download,
|
||||
// `Play`/`Stop`/`More` 是**填充**字形,pages 里禁用(tests/no_hardcoded_tokens):
|
||||
// 光影借用 `Expand`(描边的四散光线),语义最近又不撞语汇
|
||||
unlucky_presenter::dto::ContentKind::Shader => IconKind::Expand,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn sort_label(sort: ContentSort, catalog: Catalog) -> &'static str {
|
||||
match sort {
|
||||
ContentSort::Relevance => catalog.text(Key::ContentSortRelevance),
|
||||
ContentSort::Downloads => catalog.text(Key::ContentSortDownloads),
|
||||
ContentSort::Newest => catalog.text(Key::ContentSortNewest),
|
||||
ContentSort::Updated => catalog.text(Key::ContentSortUpdated),
|
||||
}
|
||||
}
|
||||
|
||||
/// 状态分派(四态各一支;`Ready` 由调用方画列表 + 详情)。
|
||||
pub(super) fn status_body<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
model: &'a Content,
|
||||
) -> Option<Element<'a, PageMessage>> {
|
||||
match &model.status {
|
||||
Status::Loading => Some(loading(ui, catalog)),
|
||||
Status::Empty => Some(empty(ui, catalog)),
|
||||
Status::Failed(reason) => Some(failed(ui, catalog, reason.as_str())),
|
||||
Status::Ready => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! 详情面板:选中项的支持范围 + 目标实例 + 装入(不兼容时**禁用并逐条给原因**)。
|
||||
//!
|
||||
//! 从 `pages/content/parts.rs` 拆出(那个文件已贴 400 行硬上限)。
|
||||
//!
|
||||
//! 这块是「一键装入指定实例」的**唯一**入口:按钮能不能按由 [`Content::can_install`] 决定,
|
||||
//! 按不动时旁边逐条列出原因——判定与画法分开,`model/tests.rs` 才能不开图形环境就钉住它。
|
||||
|
||||
use iced::widget::{column, container};
|
||||
use iced::{Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
|
||||
use crate::app::components::buttons::{self, Button};
|
||||
use crate::app::components::divider;
|
||||
use crate::app::components::selection::{chip, Chip};
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::model::{capitalize, Content};
|
||||
use super::super::Message as ContentMessage;
|
||||
use super::{intent, labelled};
|
||||
use crate::pages::frame;
|
||||
use crate::pages::Message as PageMessage;
|
||||
|
||||
/// 详情面板:选中项的支持范围 + 目标实例 + 装入(不兼容时**禁用并给原因**)。
|
||||
pub(crate) fn detail<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
model: &'a Content,
|
||||
) -> Element<'a, PageMessage> {
|
||||
let Some(hit) = model.selected_hit() else {
|
||||
return container(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.text(Key::ContentDetailEmpty),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
))
|
||||
.width(Length::Fill)
|
||||
.into();
|
||||
};
|
||||
|
||||
let head = column![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::TitleMedium,
|
||||
true,
|
||||
hit.title.as_str(),
|
||||
ui.color(Role::OnSurface)
|
||||
),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
hit.supporting.as_str(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
hit.summary.as_str(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill);
|
||||
|
||||
let meta = column![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
hit.downloads.as_str(),
|
||||
ui.color(Role::OnSurfaceVariant)
|
||||
),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
hit.updated.as_str(),
|
||||
ui.color(Role::OnSurfaceVariant)
|
||||
),
|
||||
support_line(ui, catalog, Key::ContentDetailVersions, &hit.versions),
|
||||
support_line(ui, catalog, Key::ContentDetailLoaders, &hit.loaders),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill);
|
||||
|
||||
let mut body = column![head, divider::full(ui), meta].spacing(ui.space(Space::X3));
|
||||
|
||||
// 目标实例:一行 chip(选中的那个高亮)
|
||||
if model.targets.is_empty() {
|
||||
body = body.push(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.text(Key::ContentNoInstances),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
} else {
|
||||
let mut targets = Vec::new();
|
||||
for target in &model.targets {
|
||||
targets.push(chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
target.name.as_str(),
|
||||
model.target.as_deref() == Some(target.id.as_str()),
|
||||
intent(ContentMessage::SetTarget(target.id.clone())),
|
||||
),
|
||||
));
|
||||
}
|
||||
body = body.push(labelled(
|
||||
ui,
|
||||
catalog.text(Key::ContentTargetInstance),
|
||||
targets,
|
||||
));
|
||||
}
|
||||
|
||||
// 装入:能装 ⇒ 实心按钮;不能 ⇒ 禁用 + 逐条原因(**点之前**就说清楚)
|
||||
if model.can_install() {
|
||||
body = body.push(buttons::filled(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::ContentInstall),
|
||||
intent(ContentMessage::Install),
|
||||
)
|
||||
.leading(IconKind::Download),
|
||||
));
|
||||
} else {
|
||||
body = body.push(buttons::outlined(
|
||||
ui,
|
||||
Button::disabled(catalog.text(Key::ContentInstall)),
|
||||
));
|
||||
for reason in model.blocker_reasons(catalog) {
|
||||
body = body.push(frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
reason,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
container(body.spacing(ui.space(Space::X3)))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 「游戏版本:1.20.1, 1.20.4」一行(空列表给 `—`,不编造)。
|
||||
fn support_line<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
key: Key,
|
||||
values: &[String],
|
||||
) -> Element<'a, PageMessage> {
|
||||
let list = if values.is_empty() {
|
||||
"—".to_string()
|
||||
} else {
|
||||
// 加载器是站点给的小写 facet(`fabric`):这一层只管怎么显示
|
||||
values
|
||||
.iter()
|
||||
.map(|value| capitalize(value))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
};
|
||||
frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.render(key, &[("list", &list)]),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! 筛选条:来源 / 排序 / 游戏版本 / 加载器四组 chip,外加分组标签与换行容器。
|
||||
//!
|
||||
//! 从 `pages/content/parts.rs` 拆出(那个文件已贴 400 行硬上限)。
|
||||
//!
|
||||
//! 缺 key 的源(CurseForge)画成**禁用**而不是消失:筛选条要能被发现,也要能解释自己。
|
||||
|
||||
use iced::widget::{column, container, row};
|
||||
use iced::{Alignment, Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{loader_facet, ContentSort, ContentSource, LoaderKind};
|
||||
|
||||
use crate::app::components::selection::{chip, Chip};
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::model::{loader_name, Content};
|
||||
use super::super::Message as ContentMessage;
|
||||
use super::{intent, sort_label};
|
||||
use crate::pages::frame;
|
||||
use crate::pages::Message as PageMessage;
|
||||
|
||||
/// 来源 + 排序 + 版本 + 加载器四组 chip(一行放不下就换行,`row` 变 `column`)。
|
||||
pub(crate) fn filters<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
model: &'a Content,
|
||||
) -> Element<'a, PageMessage> {
|
||||
let mut source = Vec::new();
|
||||
for item in ContentSource::ALL {
|
||||
let available = match item {
|
||||
ContentSource::Modrinth => true,
|
||||
ContentSource::Curseforge => model.curseforge,
|
||||
};
|
||||
let label = match item {
|
||||
ContentSource::Modrinth => catalog.text(Key::ContentSourceModrinth),
|
||||
ContentSource::Curseforge => catalog.text(Key::ContentSourceCurseforge),
|
||||
};
|
||||
let mut spec = Chip::filter(
|
||||
label,
|
||||
model.query.source == item,
|
||||
intent(ContentMessage::SetSource(item)),
|
||||
);
|
||||
if !available {
|
||||
// 缺 key 的源:**禁用并说明**,不是静默消失(否则用户找不到它)
|
||||
spec = spec.disabled();
|
||||
}
|
||||
source.push(chip(ui, spec));
|
||||
}
|
||||
|
||||
let mut sort = Vec::new();
|
||||
for item in ContentSort::ALL {
|
||||
sort.push(chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
sort_label(item, catalog),
|
||||
model.query.sort == item,
|
||||
intent(ContentMessage::SetSort(item)),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let mut versions = vec![chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
catalog.text(Key::ContentFilterAny),
|
||||
model.query.version.is_none(),
|
||||
intent(ContentMessage::SetVersion(None)),
|
||||
),
|
||||
)];
|
||||
for version in &model.versions {
|
||||
versions.push(chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
version.as_str(),
|
||||
model.query.version.as_deref() == Some(version.as_str()),
|
||||
intent(ContentMessage::SetVersion(Some(version.clone()))),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let mut loaders = vec![chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
catalog.text(Key::ContentFilterAny),
|
||||
model.query.loader.is_none(),
|
||||
intent(ContentMessage::SetLoader(None)),
|
||||
),
|
||||
)];
|
||||
for loader in [
|
||||
LoaderKind::Fabric,
|
||||
LoaderKind::Forge,
|
||||
LoaderKind::NeoForge,
|
||||
LoaderKind::Quilt,
|
||||
] {
|
||||
let usable = loader_facet(loader).is_some();
|
||||
let mut spec = Chip::filter(
|
||||
loader_name(loader),
|
||||
model.query.loader == Some(loader),
|
||||
intent(ContentMessage::SetLoader(Some(loader))),
|
||||
);
|
||||
if !usable {
|
||||
spec = spec.disabled();
|
||||
}
|
||||
loaders.push(chip(ui, spec));
|
||||
}
|
||||
|
||||
let mut column = column![
|
||||
labelled(ui, catalog.text(Key::ContentFilterVersion), versions),
|
||||
labelled(ui, catalog.text(Key::ContentFilterLoader), loaders),
|
||||
wrapped_row(ui, source),
|
||||
wrapped_row(ui, sort),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill);
|
||||
|
||||
if !model.curseforge {
|
||||
column = column.push(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.text(Key::ContentSourceUnavailable),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
}
|
||||
column.into()
|
||||
}
|
||||
|
||||
/// 分组标签 + 一行 chip(标签左、chip 右,纵向居中对齐)。
|
||||
pub(crate) fn labelled<'a>(
|
||||
ui: &'a Ui,
|
||||
label: &'a str,
|
||||
chips: Vec<Element<'a, PageMessage>>,
|
||||
) -> Element<'a, PageMessage> {
|
||||
row![
|
||||
container(frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
label,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)),
|
||||
wrapped_row(ui, chips),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// `Vec<Element>` → 一行(顺序就是声明顺序)。
|
||||
pub(crate) fn wrapped_row<'a>(
|
||||
ui: &'a Ui,
|
||||
chips: Vec<Element<'a, PageMessage>>,
|
||||
) -> Element<'a, PageMessage> {
|
||||
row(chips)
|
||||
.spacing(ui.space(Space::X1))
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! 内容库页的守门(M109):演示数据、四类内容、页面自留状态的默认值。
|
||||
//!
|
||||
//! 视图模型的判定在 `model/tests.rs`,栏几何在 `layout.rs`,这里只管「演示数据真的装得满」——
|
||||
//! 出图靠它,装不满就说明 `demo()` 与 `Content` 的字段对不上。
|
||||
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dto::ContentKind;
|
||||
|
||||
use super::model::{PageState, Status};
|
||||
use super::{demo, Content};
|
||||
|
||||
#[test]
|
||||
fn the_page_state_starts_on_mods_with_nothing_selected() {
|
||||
let page = PageState::default();
|
||||
assert_eq!(page.kind, ContentKind::Mod);
|
||||
assert!(page.selected.is_none());
|
||||
assert!(page.target.is_none());
|
||||
assert!(page.draft.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_kind_gets_a_full_demo_screen() {
|
||||
// 四种内容各出一屏(状态矩阵的前 8 张靠这一条)
|
||||
for kind in ContentKind::ALL {
|
||||
let model: Content = demo(kind, Lang::En);
|
||||
assert_eq!(model.kind, kind, "{} 的演示数据换了类型", kind.slug());
|
||||
assert_eq!(model.status, Status::Ready, "{}", kind.slug());
|
||||
assert_eq!(model.hits.len(), 4, "{} 的命中项不足", kind.slug());
|
||||
assert!(
|
||||
model.selected.is_some(),
|
||||
"演示数据要选中一条(详情面板才有内容)"
|
||||
);
|
||||
assert!(model.total > 0);
|
||||
assert!(model.has_more, "演示数据要能画「加载更多」");
|
||||
// 整合包**不**在这一条里断(它天生不可装:`ContentKind::Modpack.dir()` 是 `None`),
|
||||
// 由下一条用例专钉。
|
||||
if kind != ContentKind::Modpack {
|
||||
assert!(
|
||||
model.can_install(),
|
||||
"默认那一屏应当是「能装」的({})",
|
||||
kind.slug()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_modpack_screen_is_browsable_but_never_installable() {
|
||||
// 整合包:检索能看,装入一律禁用(M111 未落地)——演示图上必须看得到原因
|
||||
let model = demo(ContentKind::Modpack, Lang::En);
|
||||
assert!(model.selected.is_some());
|
||||
assert!(!model.can_install());
|
||||
assert_eq!(
|
||||
model.blocker_reasons(Lang::En.catalog()).len(),
|
||||
1,
|
||||
"整合包只有一条原因(导入管线未落地)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_installable_kinds_block_only_when_the_target_disagrees() {
|
||||
for kind in [
|
||||
ContentKind::Mod,
|
||||
ContentKind::Resourcepack,
|
||||
ContentKind::Shader,
|
||||
] {
|
||||
let model = demo(kind, Lang::En);
|
||||
assert!(
|
||||
model.blocker_reasons(Lang::En.catalog()).is_empty(),
|
||||
"{} 的默认演示项应当可装",
|
||||
kind.slug()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_demo_selection_is_always_one_of_the_hits() {
|
||||
for kind in ContentKind::ALL {
|
||||
let model = demo(kind, Lang::En);
|
||||
let selected = model.selected.clone().expect("有选中项");
|
||||
assert!(
|
||||
model.hits.iter().any(|hit| hit.id == selected),
|
||||
"{} 的选中项不在命中列表里(详情面板会是空的)",
|
||||
kind.slug()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
//! 内容库页的整页装配:页头(含一级切换)+ 左侧类型栏 + 主列(筛选/结果)+ 详情 + 安装面板。
|
||||
//!
|
||||
//! ## 版面(几何在 [`super::layout`],这里只消费结果)
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌─ 页头:Content [版本管理 | 内容库] ─┐
|
||||
//! ├──────┬────────────────────────┬──────────────┤
|
||||
//! │ 类型 │ 搜索 + 四组 chip │ 详情 / 装入 │
|
||||
//! │ 四条 │ 计数头 │ 原因逐条列出 │
|
||||
//! │ 侧栏 │ 结果列表(独立滚动) │ │
|
||||
//! │ │ 加载更多 / 没有更多了 │ │
|
||||
//! ├──────┴────────────────────────┴──────────────┤
|
||||
//! │ 安装面板(**下载页那块**,只在有安装时出现) │
|
||||
//! └──────────────────────────────────────────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! ## 为什么类型栏用 `list_item`(与 M107 的版本类型栏同一套)
|
||||
//!
|
||||
//! 四条内容类型是**一级侧栏**(页面内导航),几何与状态层必须与版本管理页一致:`list_item`
|
||||
//! 自带 60 高两行档与选中态,页面不自己写 hover/pressed。窄档(`Tight`)退成一行 chip——
|
||||
//! 栏宽没了就不该硬塞一条 140px 的栏。
|
||||
|
||||
use iced::widget::{column, container, row};
|
||||
use iced::{Element, Length};
|
||||
|
||||
use unlucky_design::spacing::Space;
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::ContentKind;
|
||||
|
||||
use crate::app::components::divider;
|
||||
use crate::app::components::navigation::list_item::{list_item, ListItem};
|
||||
use crate::app::components::selection::{chip, Chip};
|
||||
use crate::theme::Ui;
|
||||
|
||||
use crate::pages::download;
|
||||
use crate::pages::frame::{self, Header};
|
||||
use crate::pages::layout::FrameMetrics;
|
||||
use crate::pages::Message as PageMessage;
|
||||
|
||||
use super::layout::{self as content_layout, Rails, Tier};
|
||||
use super::model::{Content, Status};
|
||||
use super::parts;
|
||||
use super::Message as ContentMessage;
|
||||
|
||||
/// 整页入口(由 `pages/mod.rs` 的 `render` 调;页面层不认识 presenter 类型)。
|
||||
pub(super) fn view<'a>(
|
||||
ui: &'a Ui,
|
||||
metrics: FrameMetrics,
|
||||
model: &'a Content,
|
||||
lang: unlucky_i18n::Lang,
|
||||
) -> Element<'a, PageMessage> {
|
||||
let catalog = lang.catalog();
|
||||
let header = Header::new(catalog.text(Key::ContentTitle))
|
||||
.subtitle(catalog.text(Key::ContentSubtitle))
|
||||
.action(parts::section_switcher(ui, catalog, true));
|
||||
frame::page(ui, metrics, header, body(ui, metrics, catalog, model))
|
||||
}
|
||||
|
||||
fn body<'a>(
|
||||
ui: &'a Ui,
|
||||
metrics: FrameMetrics,
|
||||
catalog: Catalog,
|
||||
model: &'a Content,
|
||||
) -> Element<'a, PageMessage> {
|
||||
let rails = content_layout::resolve(metrics.available);
|
||||
|
||||
let mut columns = row![kind_rail(ui, catalog, model, rails)]
|
||||
.spacing(rails.gap)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
if rails.tier == Tier::Tight {
|
||||
// 窄到放不下侧栏:类型条跑到主列上方(同一个 `kind_rail`,chip 形态)
|
||||
let main = column![
|
||||
kind_rail(ui, catalog, model, rails),
|
||||
divider::full(ui),
|
||||
main_column(ui, catalog, model, rails)
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
columns = row![main].width(Length::Fill).height(Length::Fill);
|
||||
} else {
|
||||
columns = columns.push(main_column(ui, catalog, model, rails));
|
||||
if rails.tier.has_detail() {
|
||||
columns = columns.push(detail_column(ui, catalog, model, rails));
|
||||
}
|
||||
}
|
||||
|
||||
let mut stack = column![columns]
|
||||
.spacing(ui.space(Space::X3))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
if let Some(panel) = &model.panel {
|
||||
// **同一块安装面板**(下载页那个函数):内容库不另画一套进度/失败/取消。
|
||||
stack = stack
|
||||
.push(divider::full(ui))
|
||||
.push(download::install_panel(ui, catalog, panel));
|
||||
}
|
||||
stack.into()
|
||||
}
|
||||
|
||||
/// 左侧四条内容类型(`Wide`/`Compact` 是列表项,`Narrow` 是图标项,`Tight` 是一行 chip)。
|
||||
fn kind_rail<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
model: &'a Content,
|
||||
rails: Rails,
|
||||
) -> Element<'a, PageMessage> {
|
||||
if rails.tier == Tier::Tight {
|
||||
let mut chips = Vec::new();
|
||||
for kind in ContentKind::ALL {
|
||||
chips.push(chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
kind_label(kind, catalog),
|
||||
model.kind == kind,
|
||||
PageMessage::Content(ContentMessage::SetKind(kind)),
|
||||
),
|
||||
));
|
||||
}
|
||||
return row(chips)
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill)
|
||||
.into();
|
||||
}
|
||||
|
||||
let labels = rails.tier.rail_has_labels();
|
||||
let mut items = column(Vec::new())
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill);
|
||||
for kind in ContentKind::ALL {
|
||||
let label = kind_label(kind, catalog);
|
||||
// 图标档没有文字位:标签仍传(无障碍/日志用),宽度由几何裁掉
|
||||
let mut item = ListItem::new(
|
||||
if labels { label } else { " " },
|
||||
PageMessage::Content(ContentMessage::SetKind(kind)),
|
||||
)
|
||||
.leading(parts::kind_icon(kind));
|
||||
if model.kind == kind {
|
||||
item = item.selected();
|
||||
}
|
||||
items = items.push(list_item(ui, item));
|
||||
}
|
||||
|
||||
container(items)
|
||||
.width(Length::Fixed(rails.rail))
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 主列:搜索条 + 筛选 + 计数 + 结果(或四态之一)。
|
||||
fn main_column<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
model: &'a Content,
|
||||
rails: Rails,
|
||||
) -> Element<'a, PageMessage> {
|
||||
// 主列的横向内边距不在这里加:`rails.list` 已经把可用宽算干了,再加 2×X2 会让整排
|
||||
// 溢出 32px(详情列被切掉一半,复审截图上正是这条)
|
||||
let mut head = column![parts::search(ui, catalog, model)]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill);
|
||||
|
||||
// 筛选条**永远在**:它是控制面,不是数据。加载中把筛选条藏起来,用户就没法在慢网络下
|
||||
// 换条件或换类型(只能干等),这是「加载态顺手把入口收走」的经典坑。
|
||||
head = head.push(parts::filters(ui, catalog, model));
|
||||
head = head.push(divider::full(ui));
|
||||
if model.status == Status::Ready {
|
||||
// 计数头只在有数据时出现:0 条的「0 results」和空态说的是同一件事,画两遍是噪音
|
||||
head = head.push(parts::count_head(ui, catalog, model));
|
||||
}
|
||||
|
||||
let body: Element<'a, PageMessage> = if let Some(state) = parts::status_body(ui, catalog, model)
|
||||
{
|
||||
state
|
||||
} else {
|
||||
{
|
||||
let mut list = column![parts::list(ui, catalog, model, rails)]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
// 紧凑档没有独立详情列:详情压在列表下面(整页替换的成本比「多一块」高)
|
||||
if !rails.tier.has_detail() {
|
||||
list = list
|
||||
.push(divider::full(ui))
|
||||
.push(parts::detail(ui, catalog, model));
|
||||
}
|
||||
list.push(parts::more(ui, catalog, model)).into()
|
||||
}
|
||||
};
|
||||
|
||||
column![head, body]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fixed(rails.list))
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 详情列(只在 `Wide` / `Narrow` 存在)。
|
||||
fn detail_column<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
model: &'a Content,
|
||||
rails: Rails,
|
||||
) -> Element<'a, PageMessage> {
|
||||
container(parts::detail(ui, catalog, model))
|
||||
.width(Length::Fixed(rails.detail))
|
||||
.height(Length::Fill)
|
||||
.style(move |_theme| container::Style::default())
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 四种内容类型的界面名。
|
||||
fn kind_label(kind: ContentKind, catalog: Catalog) -> &'static str {
|
||||
match kind {
|
||||
ContentKind::Modpack => catalog.text(Key::ContentKindModpack),
|
||||
ContentKind::Mod => catalog.text(Key::ContentKindMod),
|
||||
ContentKind::Resourcepack => catalog.text(Key::ContentKindResourcepack),
|
||||
ContentKind::Shader => catalog.text(Key::ContentKindShader),
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,38 @@ pub use demo::demo;
|
||||
pub use model::{cancelled_toast, Category, Download};
|
||||
pub use options::{InstallDraft, Loader};
|
||||
|
||||
// M109:内容库页复用**同一块**安装面板(同一个函数、同一套文案与几何),不另写一套。
|
||||
//
|
||||
// 两个薄包装为什么必须在**这个文件**里:①`install::panel` 是 `pub(super)`(只在 `pages::download`
|
||||
// 可见),再导出到 crate 会撞 `E0364`;②面板的入参是 `&Download`,而 `InstallView` 是页面私有
|
||||
// 类型(M105 起不再从本模块导出),所以「快照 → 面板形状」的转换也留在这里——内容库只拿到
|
||||
// `Option<Download>`,它不需要也不该认识 `InstallView`。
|
||||
pub(crate) fn install_panel<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
model: &'a Download,
|
||||
) -> Element<'a, Message> {
|
||||
install::panel(ui, catalog, model)
|
||||
}
|
||||
|
||||
/// 快照 → 面板要的 `Download` 形状(**只填 `install`**)。
|
||||
///
|
||||
/// 「装完 / 已取消」这类没有动作可给的终态返回 `None`(与下载页同一个判断
|
||||
/// `InstallView::finished`):留着只会让面板停在一个点不动的状态上。
|
||||
pub(crate) fn install_panel_model(
|
||||
progress: &unlucky_presenter::dto::InstallProgressView,
|
||||
catalog: Catalog,
|
||||
) -> Option<Download> {
|
||||
let view = model::InstallView::of(progress, catalog);
|
||||
if view.finished() {
|
||||
return None;
|
||||
}
|
||||
Some(Download {
|
||||
install: Some(view),
|
||||
..Download::loading()
|
||||
})
|
||||
}
|
||||
|
||||
use iced::widget::column;
|
||||
use iced::{Element, Length};
|
||||
|
||||
@@ -120,6 +152,7 @@ use crate::app::components::buttons::{self, Button};
|
||||
use crate::app::components::divider;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::content;
|
||||
use super::frame::{self, Header};
|
||||
use super::layout::{FrameMetrics, VersionRails};
|
||||
use super::{Action, Message};
|
||||
@@ -138,6 +171,9 @@ pub fn view<'a>(
|
||||
|
||||
let header = Header::new(catalog.text(Key::DownloadTitle))
|
||||
.subtitle(catalog.text(Key::DownloadSubtitle))
|
||||
// M109:一级切换「版本管理 | 内容库」。两页共用同一条导航轨项(`NavSection::Download`),
|
||||
// 所以这个切换是进内容库的唯一产品入口——少了它,内容库只能靠 `ULL_START_ROUTE` 到达。
|
||||
.action(content::section_switcher(ui, catalog))
|
||||
.action(buttons::text(
|
||||
ui,
|
||||
Button::new(
|
||||
|
||||
@@ -11,6 +11,7 @@ use unlucky_i18n::LangChoice;
|
||||
use unlucky_presenter::router::Route;
|
||||
|
||||
use super::appearance;
|
||||
use super::content;
|
||||
use super::download::{Category, Loader};
|
||||
use super::settings;
|
||||
|
||||
@@ -85,6 +86,11 @@ pub enum Message {
|
||||
/// (`app/update/settings/language_appearance.rs`)是 language+appearance 线的文件。
|
||||
/// 代价是本文件多一个变体;收口方向写在 `settings/general.rs` 的文档里。
|
||||
Language(LangChoice),
|
||||
/// 内容库页的一组消息(**M109**):与设置页同一个范式(页面自己的枚举 + 一个投递口)。
|
||||
///
|
||||
/// 加一条内容库消息只动 `pages/content/message.rs`,不动本枚举的正文,也不动
|
||||
/// `app/update/page_messages.rs` 那条不许 `_ =>` 的 match。
|
||||
Content(content::Message),
|
||||
}
|
||||
|
||||
/// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。
|
||||
|
||||
@@ -109,6 +109,7 @@ pub mod harness;
|
||||
pub mod layout;
|
||||
pub mod model;
|
||||
|
||||
pub mod content;
|
||||
mod download;
|
||||
mod instances;
|
||||
mod logs;
|
||||
@@ -213,6 +214,8 @@ fn render<'a>(
|
||||
Screen::Instances => instances::view(ui, metrics, model),
|
||||
// M106:下载页此前写死成 `Lang::En`,现在跟 `model.lang`(页面文案跟着界面语言走)。
|
||||
Screen::Download => download::view(ui, metrics, &model.download, model.lang),
|
||||
// M109:内容库页同样跟 `model.lang`
|
||||
Screen::Content => content::view(ui, metrics, &model.content, model.lang),
|
||||
Screen::Settings => settings::view(ui, metrics, states, language),
|
||||
Screen::Logs => logs::view(ui, metrics, &model.logs, model.lang),
|
||||
}
|
||||
@@ -272,6 +275,7 @@ pub fn demo_model(screen: Screen, window_width: f32) -> Model {
|
||||
match screen {
|
||||
Screen::Instances => model.instances = instances::demo(),
|
||||
Screen::Download => model.download = download::demo(unlucky_i18n::Lang::En.catalog()),
|
||||
Screen::Content => model.content = content::demo(content::ContentKind::Mod, model.lang),
|
||||
Screen::Logs => model.logs = logs::demo(model.lang),
|
||||
Screen::Settings => {}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ use unlucky_presenter::router::NavSection;
|
||||
|
||||
use crate::app::components::interaction::Interaction;
|
||||
|
||||
use super::content::Content;
|
||||
use super::logs::Logs;
|
||||
use super::{Download, Screen};
|
||||
|
||||
@@ -89,6 +90,8 @@ pub struct Model {
|
||||
pub download: Download,
|
||||
/// 日志页的数据(M95:presenter 快照 → 日志行 + 诊断结论,见 [`super::logs`])。
|
||||
pub logs: Logs,
|
||||
/// 内容库页的数据(M109:检索条件 / 命中项 / 装入闸门,见 [`super::content`])。
|
||||
pub content: Content,
|
||||
/// 界面语言(M106):页面文案走 i18n key,语言由 app 层决定后灌进来。
|
||||
///
|
||||
/// 谁填:`app/state.rs` 的 `App::refresh_view`(`LangState::lang()` → `view::model(..)`),
|
||||
@@ -112,6 +115,8 @@ impl Model {
|
||||
instances: Instances::default(),
|
||||
download: Download::loading(),
|
||||
logs: Logs::default(),
|
||||
// 演示模型也从这个空壳起步(`demo_model` 再按页面填数据)
|
||||
content: super::content::demo(unlucky_presenter::dto::ContentKind::Mod, Lang::En),
|
||||
lang: Lang::En,
|
||||
forced: None,
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ pub enum Screen {
|
||||
Instances,
|
||||
/// **版本管理**(原下载页;单列页,但正文自己切三栏:类型 / 主版本 / 列表)。
|
||||
Download,
|
||||
/// 内容库(M109):整合包 / Mod / 资源包 / 光影。
|
||||
Content,
|
||||
/// 设置(supporting-pane)。
|
||||
Settings,
|
||||
/// 日志(单列,两行页头)。
|
||||
@@ -22,9 +24,10 @@ pub enum Screen {
|
||||
|
||||
impl Screen {
|
||||
/// 全部页面(截图矩阵按这个顺序铺)。
|
||||
pub const ALL: [Screen; 4] = [
|
||||
pub const ALL: [Screen; 5] = [
|
||||
Screen::Instances,
|
||||
Screen::Download,
|
||||
Screen::Content,
|
||||
Screen::Settings,
|
||||
Screen::Logs,
|
||||
];
|
||||
@@ -34,6 +37,7 @@ impl Screen {
|
||||
match self {
|
||||
Screen::Instances => "instances",
|
||||
Screen::Download => "download",
|
||||
Screen::Content => "content",
|
||||
Screen::Settings => "settings",
|
||||
Screen::Logs => "logs",
|
||||
}
|
||||
@@ -49,7 +53,9 @@ impl Screen {
|
||||
match self {
|
||||
Screen::Instances => PaneKit::ListDetail,
|
||||
Screen::Settings => PaneKit::Supporting,
|
||||
Screen::Download | Screen::Logs => PaneKit::Single,
|
||||
// 内容库自带三栏(`pages/content/layout.rs`),版面基座仍是单列:
|
||||
// 它的三栏是「页内栏」而不是 layout 模块那三种整页版面。
|
||||
Screen::Download | Screen::Content | Screen::Logs => PaneKit::Single,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +66,7 @@ impl Screen {
|
||||
Some(match route {
|
||||
Route::Instances | Route::Detail(_) => Screen::Instances,
|
||||
Route::Download => Screen::Download,
|
||||
Route::Content => Screen::Content,
|
||||
Route::Logs => Screen::Logs,
|
||||
Route::Settings => Screen::Settings,
|
||||
Route::Agent => return None,
|
||||
@@ -70,7 +77,8 @@ impl Screen {
|
||||
pub const fn nav(self) -> NavSection {
|
||||
match self {
|
||||
Screen::Instances => NavSection::Instances,
|
||||
Screen::Download => NavSection::Download,
|
||||
// M109(B1 裁定):内容库与版本管理共用同一条导航轨项
|
||||
Screen::Download | Screen::Content => NavSection::Download,
|
||||
Screen::Logs => NavSection::Logs,
|
||||
Screen::Settings => NavSection::Settings,
|
||||
}
|
||||
|
||||
@@ -23,10 +23,14 @@ fn ui() -> Ui {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_screens_answer_the_mission_list() {
|
||||
assert_eq!(Screen::ALL.len(), 4);
|
||||
fn five_screens_answer_the_mission_list() {
|
||||
// M109:内容库是第五页(`download` 与 `content` 共用导航轨的「下载」项)
|
||||
assert_eq!(Screen::ALL.len(), 5);
|
||||
let slugs: Vec<&str> = Screen::ALL.iter().map(|screen| screen.slug()).collect();
|
||||
assert_eq!(slugs, vec!["instances", "download", "settings", "logs"]);
|
||||
assert_eq!(
|
||||
slugs,
|
||||
vec!["instances", "download", "content", "settings", "logs"]
|
||||
);
|
||||
for screen in Screen::ALL {
|
||||
assert_eq!(Screen::from_slug(screen.slug()), Some(screen));
|
||||
}
|
||||
@@ -38,6 +42,8 @@ fn screens_declare_the_layout_their_page_needs() {
|
||||
assert_eq!(Screen::Instances.kit(), PaneKit::ListDetail);
|
||||
assert_eq!(Screen::Settings.kit(), PaneKit::Supporting);
|
||||
assert_eq!(Screen::Download.kit(), PaneKit::Single);
|
||||
// 内容库自带页内三栏,版面基座仍是单列(见 `pages/content/layout.rs`)
|
||||
assert_eq!(Screen::Content.kit(), PaneKit::Single);
|
||||
assert_eq!(Screen::Logs.kit(), PaneKit::Single);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user